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();
+ });
+ });
});