diff --git a/README.md b/README.md index 87c37b06..21f905d4 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras) | `dependencies` | | :white_check_mark: | Other cameras that this camera should depend upon. See [camera dependencies](#camera-dependencies-configuration) below. | | `triggers` | | :white_check_mark: | Define what should cause this camera to update/trigger. See [camera triggers](#camera-trigger-configuration) below. | | `webrtc_card` | | :white_check_mark: | The WebRTC entity/URL to use for this camera with the `webrtc-card` live provider. See below. | +| `cast` | | :white_check_mark: | Configuration that controls how this camera is "casted" / sent to media players. See below. | @@ -332,6 +333,35 @@ cameras: | `occupancy` | `true` | :white_check_mark: | Whether to not to trigger the camera by automatically detecting and using the occupancy `binary_sensor` for this camera and its configured zones and labels. This autodetection only works for Frigate cameras, and only when the occupancy `binary_sensor` entity has been enabled in Home Assistant. If this camera has configured zones, only occupancy sensors for those zones are used -- if the overall _camera_ occupancy sensor is also required, it can be manually added to `entities`. If this camera has configured labels, only occupancy sensors for those labels are used.| | `entities` | | :white_check_mark: | Whether to not to trigger the camera when the state of any Home Assistant entity becomes active (i.e. state becomes `on` or `open`). This works for Frigate or non-Frigate cameras.| +#### Camera Cast Configuration + +The `cast` block configures what how a camera is cast / sent to media players. + +```yaml +cameras: + - cast: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `method` | `standard` | :white_check_mark: | Whether to use `standard` media casting to send the live view to your media player, or to instead cast a `dashboard` you have manually setup. Casting a dashboard supports a much wider variety of video media, including low latency video providers (e.g. `go2rtc`). This setting has no effect on casting non-live media. | +| `dashboard` | | :white_check_mark: | Configuration for the dashboard to cast. See below. | + +See the [dashboard method cast example](#cast-dashboard-example). + +#### Camera Cast Dashboard Configuration + +```yaml +cameras: + - cast: + dashboard: +``` + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `dashboard_path` | | :white_check_mark: | A required field that specifies the name of the dashboard to cast. You can see this name in your HA URL when you visit the dashboard. | +| `view_path` | | :white_check_mark: | A required field that specifies view/"tab" on that dashboard to cast. This is the value you have specified in the `url` field of the view configuration on the dashboard. | + #### Camera IDs: Referring to cameras in card configuration @@ -1673,8 +1703,6 @@ Pan around a large camera view to only show part of the video feed in the card a ## Examples -## Examples - ### Illustrative Expanded Configuration Reference **Caution**: 🚩 Just copying this full reference into your configuration will cause you a significant maintenance burden. Don't do it! Please only specify what you need as defaults can / do change continually as this card develops. Almost all the values shown here are the defaults (except in cases where is no default, parameters are added here for illustrative purposes). @@ -1710,6 +1738,8 @@ cameras: occupancy: true entities: - binary_sensor.front_door_sensor + cast: + method: standard - camera_entity: camera.entrance live_provider: webrtc-card engine: auto @@ -1747,6 +1777,11 @@ cameras: - mjpeg stream: sitting_room url: 'https://my.custom.go2rtc.backend' + cast: + method: dashboard + dashboard: + dashboard_path: cast + view_path: front-door - camera_entity: camera.sitting_room_webrtc_card live_provider: webrtc_card webrtc_card: @@ -3942,6 +3977,49 @@ overrides: ``` + + +### `dashboard` cast method + +
+ Expand: Using the `dashboard` cast method to cast a low latency live provider + +This example will configure a Frigate card that can cast a dashboard view to a media player, which has a second Frigate card in panel mode with a low-latency live provider. + +#### Source card + +```yaml +type: custom:frigate-card +cameras: + - camera_entity: camera.front_door + cast: + method: dashboard + dashboard: + dashboard_path: cast + view_path: front-door +``` + +#### Dashboard configuration + +This dashboard is configured at the path `/cast/` (path referred to in `dashboard_path` above). + +```yaml +title: Frigate Card Casting +views: + - title: Casting + # This path is referred to in `view_path` above. + path: front-door + # Ensure the video is "maximized" + type: panel + cards: + - type: custom:frigate-card + cameras: + - camera_entity: camera.front_door + live_provider: go2rtc +``` + +
+ ## Card Refreshes 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/const.ts b/src/const.ts index 4cda24fe..98097196 100644 --- a/src/const.ts +++ b/src/const.ts @@ -6,6 +6,11 @@ export const CONF_CAMERAS_ARRAY_CAMERA_ENTITY = `${CONF_CAMERAS}.#.camera_entity` as const; export const CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME = `${CONF_CAMERAS}.#.frigate.camera_name` as const; +export const CONF_CAMERAS_ARRAY_CAST_METHOD = `${CONF_CAMERAS}.#.cast.method` as const; +export const CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH = + `${CONF_CAMERAS}.#.cast.dashboard.dashboard_path` as const; +export const CONF_CAMERAS_ARRAY_CAST_DASHBOARD_VIEW_PATH = + `${CONF_CAMERAS}.#.cast.dashboard.view_path` as const; export const CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID = `${CONF_CAMERAS}.#.frigate.client_id` as const; export const CONF_CAMERAS_ARRAY_FRIGATE_LABELS = diff --git a/src/editor.ts b/src/editor.ts index ba410643..294b0dd3 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -1,4 +1,8 @@ -import { fireEvent, HomeAssistant, LovelaceCardEditor } from '@dermotduffy/custom-card-helpers'; +import { + fireEvent, + HomeAssistant, + LovelaceCardEditor, +} from '@dermotduffy/custom-card-helpers'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; @@ -29,6 +33,9 @@ import { import { CONF_CAMERAS, CONF_CAMERAS_ARRAY_CAMERA_ENTITY, + CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH, + CONF_CAMERAS_ARRAY_CAST_DASHBOARD_VIEW_PATH, + CONF_CAMERAS_ARRAY_CAST_METHOD, CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS, CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS, CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME, @@ -191,6 +198,7 @@ import { const MENU_BUTTONS = 'buttons'; const MENU_CAMERAS = 'cameras'; +const MENU_CAMERAS_CAST = 'cameras.cast'; const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies'; const MENU_CAMERAS_ENGINE = 'cameras.engine'; const MENU_CAMERAS_FRIGATE = 'cameras.frigate'; @@ -561,6 +569,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor { value: 'grid', label: localize('display_modes.grid') }, ]; + protected _castMethods: EditorSelectOption[] = [ + { value: '', label: '' }, + { value: 'standard', label: localize('config.cameras.cast.methods.standard') }, + { value: 'dashboard', label: localize('config.cameras.cast.methods.dashboard') }, + ]; + public setConfig(config: RawFrigateCardConfig): void { // Note: This does not use Zod to parse the configuration, so it may be // partially or completely invalid. It's more useful to have a partially @@ -1662,6 +1676,30 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor }, )}`, )} + ${this._putInSubmenu( + MENU_CAMERAS_CAST, + cameraIndex, + 'config.cameras.cast.editor_label', + { name: 'mdi:cast' }, + html` + ${this._renderOptionSelector( + getArrayConfigPath(CONF_CAMERAS_ARRAY_CAST_METHOD, cameraIndex), + this._castMethods, + )} + ${this._renderStringInput( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH, + cameraIndex, + ), + )} + ${this._renderStringInput( + getArrayConfigPath( + CONF_CAMERAS_ARRAY_CAST_DASHBOARD_VIEW_PATH, + cameraIndex, + ), + )} + `, + )} ` : ``} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index a5e860f4..de13a746 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -10,6 +10,18 @@ "config": { "cameras": { "camera_entity": "Camera Entity", + "cast": { + "dashboard": { + "dashboard_path": "Dashboard path", + "view_path": "View path" + }, + "editor_label": "Cast Options", + "method": "Cast method", + "methods": { + "standard": "Standard", + "dashboard": "Dashboard" + } + }, "dependencies": { "all_cameras": "Show events for all cameras with this camera", "cameras": "Show events for specific cameras with this camera", @@ -444,6 +456,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", @@ -521,4 +534,4 @@ }, "select_date": "Choose date" } -} \ No newline at end of file +} diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index fa1b9a97..cbefd91d 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -10,6 +10,18 @@ "config": { "cameras": { "camera_entity": "Entità della telecamera", + "cast": { + "dashboard": { + "dashboard_path": "", + "view_path": "" + }, + "editor_label": "", + "method": "", + "methods": { + "standard": "", + "dashboard": "" + } + }, "dependencies": { "all_cameras": "Mostra eventi per tutte le telecamere con questa telecamera", "cameras": "Mostra eventi per telecamere specifiche con questa telecamera", @@ -436,6 +448,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..aa8ecc21 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -10,6 +10,18 @@ "config": { "cameras": { "camera_entity": "Entidade da Câmera", + "cast": { + "dashboard": { + "dashboard_path": "", + "view_path": "" + }, + "editor_label": "", + "method": "", + "methods": { + "standard": "", + "dashboard": "" + } + }, "dependencies": { "all_cameras": "Mostrar eventos para todas as câmeras nesta câmera", "cameras": "Mostrar eventos para câmeras específicas nesta câmera", @@ -443,6 +455,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..df256cee 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -10,6 +10,18 @@ "config": { "cameras": { "camera_entity": "Entidade da Câmera", + "cast": { + "dashboard": { + "dashboard_path": "", + "view_path": "" + }, + "editor_label": "", + "method": "", + "methods": { + "standard": "", + "dashboard": "" + } + }, "dependencies": { "all_cameras": "Mostrar eventos para todas as câmeras nesta câmera", "cameras": "Mostrar eventos para câmeras específicas nesta câmera", @@ -429,6 +441,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,85 +143,189 @@ 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(cameraManager.getStore().getCameraConfig).mockReturnValue(null); vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); - const hass = createHASS({ - 'camera.foo': createStateEntity({ - attributes: { - entity_picture: 'http://thumbnail', - }, - }), - }); - vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + 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: { - title: 'camera title', - thumb: 'http://thumbnail', + expect(api.getHASSManager().getHASS()?.callService).not.toBeCalled(); + }); + + 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: {}, + }, + ); + }); }); - 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); + 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', + }, + ); + }); - await manager.playLive('media_player.foo', 'camera'); + 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); - expect(api.getHASSManager().getHASS()?.callService).not.toBeCalled(); + 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 title and thumbnail', async () => { + 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(); + }); + }); });