Merge pull request #1296 from dermotduffy/new-way-of-casting

Add new method of casting that supports low latency live streams
This commit is contained in:
Dermot Duffy
2023-10-10 20:20:24 -07:00
committed by GitHub
13 changed files with 528 additions and 66 deletions
+80 -2
View File
@@ -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. | | `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. | | `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. | | `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. |
<a name="live-providers"></a> <a name="live-providers"></a>
@@ -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.| | `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.| | `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. |
<a name="camera-ids"></a> <a name="camera-ids"></a>
#### Camera IDs: Referring to cameras in card configuration #### 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
## Examples
### Illustrative Expanded Configuration Reference ### 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). **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 occupancy: true
entities: entities:
- binary_sensor.front_door_sensor - binary_sensor.front_door_sensor
cast:
method: standard
- camera_entity: camera.entrance - camera_entity: camera.entrance
live_provider: webrtc-card live_provider: webrtc-card
engine: auto engine: auto
@@ -1747,6 +1777,11 @@ cameras:
- mjpeg - mjpeg
stream: sitting_room stream: sitting_room
url: 'https://my.custom.go2rtc.backend' url: 'https://my.custom.go2rtc.backend'
cast:
method: dashboard
dashboard:
dashboard_path: cast
view_path: front-door
- camera_entity: camera.sitting_room_webrtc_card - camera_entity: camera.sitting_room_webrtc_card
live_provider: webrtc_card live_provider: webrtc_card
webrtc_card: webrtc_card:
@@ -3942,6 +3977,49 @@ overrides:
``` ```
</details> </details>
<a name="cast-dashboard-example"></a>
### `dashboard` cast method
<details>
<summary>Expand: Using the `dashboard` cast method to cast a low latency live provider</summary>
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
```
</details>
<a name="media-layout-examples"></a> <a name="media-layout-examples"></a>
## Card Refreshes ## Card Refreshes
+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>;
+5
View File
@@ -6,6 +6,11 @@ export const CONF_CAMERAS_ARRAY_CAMERA_ENTITY =
`${CONF_CAMERAS}.#.camera_entity` as const; `${CONF_CAMERAS}.#.camera_entity` as const;
export const CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME = export const CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME =
`${CONF_CAMERAS}.#.frigate.camera_name` as const; `${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 = export const CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID =
`${CONF_CAMERAS}.#.frigate.client_id` as const; `${CONF_CAMERAS}.#.frigate.client_id` as const;
export const CONF_CAMERAS_ARRAY_FRIGATE_LABELS = export const CONF_CAMERAS_ARRAY_FRIGATE_LABELS =
+39 -1
View File
@@ -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 { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
@@ -29,6 +33,9 @@ import {
import { import {
CONF_CAMERAS, CONF_CAMERAS,
CONF_CAMERAS_ARRAY_CAMERA_ENTITY, 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_ALL_CAMERAS,
CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS, CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS,
CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME, CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME,
@@ -191,6 +198,7 @@ import {
const MENU_BUTTONS = 'buttons'; const MENU_BUTTONS = 'buttons';
const MENU_CAMERAS = 'cameras'; const MENU_CAMERAS = 'cameras';
const MENU_CAMERAS_CAST = 'cameras.cast';
const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies'; const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies';
const MENU_CAMERAS_ENGINE = 'cameras.engine'; const MENU_CAMERAS_ENGINE = 'cameras.engine';
const MENU_CAMERAS_FRIGATE = 'cameras.frigate'; const MENU_CAMERAS_FRIGATE = 'cameras.frigate';
@@ -561,6 +569,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
{ value: 'grid', label: localize('display_modes.grid') }, { 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 { public setConfig(config: RawFrigateCardConfig): void {
// Note: This does not use Zod to parse the configuration, so it may be // 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 // 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,
),
)}
`,
)}
</div>` </div>`
: ``} : ``}
</div> </div>
+14 -1
View File
@@ -10,6 +10,18 @@
"config": { "config": {
"cameras": { "cameras": {
"camera_entity": "Camera Entity", "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": { "dependencies": {
"all_cameras": "Show events for all cameras with this camera", "all_cameras": "Show events for all cameras with this camera",
"cameras": "Show events for specific 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_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",
@@ -521,4 +534,4 @@
}, },
"select_date": "Choose date" "select_date": "Choose date"
} }
} }
+13
View File
@@ -10,6 +10,18 @@
"config": { "config": {
"cameras": { "cameras": {
"camera_entity": "Entità della telecamera", "camera_entity": "Entità della telecamera",
"cast": {
"dashboard": {
"dashboard_path": "",
"view_path": ""
},
"editor_label": "",
"method": "",
"methods": {
"standard": "",
"dashboard": ""
}
},
"dependencies": { "dependencies": {
"all_cameras": "Mostra eventi per tutte le telecamere con questa telecamera", "all_cameras": "Mostra eventi per tutte le telecamere con questa telecamera",
"cameras": "Mostra eventi per telecamere specifiche 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_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",
+13
View File
@@ -10,6 +10,18 @@
"config": { "config": {
"cameras": { "cameras": {
"camera_entity": "Entidade da Câmera", "camera_entity": "Entidade da Câmera",
"cast": {
"dashboard": {
"dashboard_path": "",
"view_path": ""
},
"editor_label": "",
"method": "",
"methods": {
"standard": "",
"dashboard": ""
}
},
"dependencies": { "dependencies": {
"all_cameras": "Mostrar eventos para todas as câmeras nesta câmera", "all_cameras": "Mostrar eventos para todas as câmeras nesta câmera",
"cameras": "Mostrar eventos para câmeras específicas 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_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",
+13
View File
@@ -10,6 +10,18 @@
"config": { "config": {
"cameras": { "cameras": {
"camera_entity": "Entidade da Câmera", "camera_entity": "Entidade da Câmera",
"cast": {
"dashboard": {
"dashboard_path": "",
"view_path": ""
},
"editor_label": "",
"method": "",
"methods": {
"standard": "",
"dashboard": ""
}
},
"dependencies": { "dependencies": {
"all_cameras": "Mostrar eventos para todas as câmeras nesta câmera", "all_cameras": "Mostrar eventos para todas as câmeras nesta câmera",
"cameras": "Mostrar eventos para câmeras específicas 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_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();
});
});
}); });