refactor: Unify substream actions into substream_{on,off} (#2497)

## Summary

- Collapse `live_substream_{on,off,select}` into a unified
`substream_{on,off}` pair, symmetric with `call_{start,end}`.
- Rename the `camera` field on the former `live_substream_select` to
`stream` (it always was a stream ID).
- Add optional `camera` field to both new actions for targeting a
non-selected base camera.
- YAML configs are migrated automatically; URL bookmarks must be updated
by hand.

## Migration

### Cycling between camera and substream (toggle button)

Before:
```yaml
tap_action:
  action: custom:advanced-camera-card-action
  advanced_camera_card_action: live_substream_on
```

After:
```yaml
tap_action:
  action: custom:advanced-camera-card-action
  advanced_camera_card_action: substream_on
```

### Selecting a specific substream

Before:
```yaml
tap_action:
  action: custom:advanced-camera-card-action
  advanced_camera_card_action: live_substream_select
  camera: camera.front_door_hd
```

After:
```yaml
tap_action:
  action: custom:advanced-camera-card-action
  advanced_camera_card_action: substream_on
  stream: camera.front_door_hd
```

### Turning the substream off

Before:
```yaml
tap_action:
  action: custom:advanced-camera-card-action
  advanced_camera_card_action: live_substream_off
```

After:
```yaml
tap_action:
  action: custom:advanced-camera-card-action
  advanced_camera_card_action: substream_off
```

### URL querystrings (manual update required)

| Before | After |
| --- | --- |
|
`?advanced-camera-card-action.live_substream_select=camera.front_door_hd`
| `?advanced-camera-card-action.substream_on=camera.front_door_hd` |
This commit is contained in:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
parent 15e335a647
commit f18e4cd4b8
32 changed files with 878 additions and 386 deletions
+42 -49
View File
@@ -228,50 +228,6 @@ action: custom:advanced-camera-card-action
advanced_camera_card_action: live advanced_camera_card_action: live
``` ```
## `live_substream_off`
Turn off the substream (if on).
```yaml
action: custom:advanced-camera-card-action
advanced_camera_card_action: live_substream_off
```
| Parameter | Description |
| ----------------------------- | --------------------------------------------- |
| `action` | Must be `custom:advanced-camera-card-action`. |
| `advanced_camera_card_action` | Must be `live_substream_off`. |
## `live_substream_on`
Turn on the first available substream. Use [Camera dependency configuration](../../cameras/README.md?id=dependencies) to configure substreams.
```yaml
action: custom:advanced-camera-card-action
advanced_camera_card_action: live_substream_on
```
| Parameter | Description |
| ----------------------------- | --------------------------------------------- |
| `action` | Must be `custom:advanced-camera-card-action`. |
| `advanced_camera_card_action` | Must be `live_substream_on`. |
## `live_substream_select`
Select a substream. Use [Camera dependency configuration](../../cameras/README.md?id=dependencies) to configure substreams.
```yaml
action: custom:advanced-camera-card-action
advanced_camera_card_action: live_substream_select
# [...]
```
| Parameter | Description |
| ----------------------------- | ------------------------------------------------------------------------------- |
| `action` | Must be `custom:advanced-camera-card-action`. |
| `advanced_camera_card_action` | Must be `live_substream_select`. |
| `camera` | The [camera ID](../../cameras/README.md?id=cameras) of the substream to select. |
## `media` ## `media`
Change to the `media` view. Change to the `media` view.
@@ -687,6 +643,43 @@ advanced_camera_card_action: status_bar
The items parameter is a list of items to `add` or `remove`. See [`custom:advanced-camera-card-status-bar-icon`](../../elements/custom/README.md?id=status-bar-icon), [`custom:advanced-camera-card-status-bar-image`](../../elements/custom/README.md?id=status-bar-image), [`custom:advanced-camera-card-status-bar-string`](../../elements/custom/README.md?id=status-bar-string) for the allowable items and their parameters. See the [fully expanded reference](./README.md?fully-expanded-reference) below for a complete example. The items parameter is a list of items to `add` or `remove`. See [`custom:advanced-camera-card-status-bar-icon`](../../elements/custom/README.md?id=status-bar-icon), [`custom:advanced-camera-card-status-bar-image`](../../elements/custom/README.md?id=status-bar-image), [`custom:advanced-camera-card-status-bar-string`](../../elements/custom/README.md?id=status-bar-string) for the allowable items and their parameters. See the [fully expanded reference](./README.md?fully-expanded-reference) below for a complete example.
## `substream_off`
Turn off a camera's substream.
```yaml
action: custom:advanced-camera-card-action
advanced_camera_card_action: substream_off
# [...]
```
| Parameter | Description |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `action` | Must be `custom:advanced-camera-card-action`. |
| `advanced_camera_card_action` | Must be `substream_off`. |
| `camera` | An optional [camera ID](../../cameras/README.md?id=cameras) whose substream override to clear. If omitted, the selected camera is used. |
## `substream_on`
Turn on a substream. Use [Camera dependency
configuration](../../cameras/README.md?id=dependencies) to configure substreams.
When `stream` is set, the action engages that named stream. When omitted, each
call advances through the camera's `substream` dependencies in order, wrapping
back to the camera itself (no substream engaged).
```yaml
action: custom:advanced-camera-card-action
advanced_camera_card_action: substream_on
# [...]
```
| Parameter | Description |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `action` | Must be `custom:advanced-camera-card-action`. |
| `advanced_camera_card_action` | Must be `substream_on`. |
| `camera` | An optional [camera ID](../../cameras/README.md?id=cameras) whose substream to engage. If omitted, the selected camera is used. |
| `stream` | An optional [camera ID](../../cameras/README.md?id=cameras) of a `substream` [dependency](../../cameras/README.md?id=dependencies) of `camera`. If omitted, the next stream in `camera`'s cycle is engaged. |
## `timeline` ## `timeline`
Change to the `timeline` view. Change to the `timeline` view.
@@ -865,20 +858,20 @@ elements:
title: Turn off substream title: Turn off substream
tap_action: tap_action:
action: custom:advanced-camera-card-action action: custom:advanced-camera-card-action
advanced_camera_card_action: live_substream_off advanced_camera_card_action: substream_off
- type: custom:advanced-camera-card-menu-icon - type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-o-circle icon: mdi:alpha-o-circle
title: Turn on substream title: Cycle substream
tap_action: tap_action:
action: custom:advanced-camera-card-action action: custom:advanced-camera-card-action
advanced_camera_card_action: live_substream_on advanced_camera_card_action: substream_on
- type: custom:advanced-camera-card-menu-icon - type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-n-circle icon: mdi:alpha-n-circle
title: Select HD substream title: Select HD substream
tap_action: tap_action:
action: custom:advanced-camera-card-action action: custom:advanced-camera-card-action
advanced_camera_card_action: live_substream_select advanced_camera_card_action: substream_on
camera: camera.front_door_hd stream: camera.front_door_hd
- type: custom:advanced-camera-card-menu-icon - type: custom:advanced-camera-card-menu-icon
icon: mdi:alpha-o-circle icon: mdi:alpha-o-circle
title: Log to console title: Log to console
+2 -2
View File
@@ -32,8 +32,8 @@ automations:
fullscreen: true fullscreen: true
actions: actions:
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: live_substream_on advanced_camera_card_action: substream_on
actions_not: actions_not:
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: live_substream_off advanced_camera_card_action: substream_off
``` ```
+6 -6
View File
@@ -77,10 +77,10 @@ automations:
fullscreen: true fullscreen: true
actions: actions:
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: live_substream_on advanced_camera_card_action: substream_on
actions_not: actions_not:
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: live_substream_off advanced_camera_card_action: substream_off
``` ```
### Fullscreen with display mode and substream switching ### Fullscreen with display mode and substream switching
@@ -117,7 +117,7 @@ elements:
advanced_camera_card_action: display_mode_select advanced_camera_card_action: display_mode_select
display_mode: single display_mode: single
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: live_substream_on advanced_camera_card_action: substream_on
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: fullscreen advanced_camera_card_action: fullscreen
automations: automations:
@@ -126,7 +126,7 @@ automations:
fullscreen: false fullscreen: false
actions: actions:
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: live_substream_off advanced_camera_card_action: substream_off
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: display_mode_select advanced_camera_card_action: display_mode_select
display_mode: grid display_mode: grid
@@ -637,10 +637,10 @@ cameras:
automations: automations:
- actions: - actions:
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: live_substream_on advanced_camera_card_action: substream_on
actions_not: actions_not:
- action: custom:advanced-camera-card-action - action: custom:advanced-camera-card-action
advanced_camera_card_action: live_substream_off advanced_camera_card_action: substream_off
conditions: conditions:
- condition: interaction - condition: interaction
interaction: true interaction: true
+5 -4
View File
@@ -33,10 +33,10 @@ To send an action to a specific named Advanced Camera Card:
> For backwards compatibility, the card will also respond to `frigate-card-action` as a query-parameter with the same syntax. > For backwards compatibility, the card will also respond to `frigate-card-action` as a query-parameter with the same syntax.
| Parameter | Description | | Parameter | Description |
| --------- | ------------------------------------------------------------------------------------------------------ | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ACTION` | One of the supported Advanced Camera Card custom actions. See [Supported Actions](#supported-actions). | | `ACTION` | One of the supported Advanced Camera Card custom actions. See [Supported Actions](#supported-actions). |
| `CARD_ID` | When specified only cards that have a [`card_id`](../configuration/README.md) parameter will act. | | `CARD_ID` | When specified only cards that have a [`card_id`](../configuration/README.md) parameter will act. |
| `VALUE` | An optional value to use with the `camera_select` and `live_substream_select` actions. | | `VALUE` | A value for actions that accept one: required by `camera_select`; optional for `substream_on` (interpreted as the `stream` to engage; omit to cycle). |
> [!TIP] > [!TIP]
> Both `.` and `:` may be used as the delimiter. If you use `:` some > Both `.` and `:` may be used as the delimiter. If you use `:` some
@@ -52,7 +52,7 @@ To send an action to a specific named Advanced Camera Card:
Only a subset of all [actions](../configuration/actions/README.md) are supported in URL form. Only a subset of all [actions](../configuration/actions/README.md) are supported in URL form.
| Action | Supported in URL | Explanation | | Action | Supported in URL | Explanation |
| ------------------------------------------------------------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| `camera_select` | :white_check_mark: | | | `camera_select` | :white_check_mark: | |
| `camera_ui` | :white_check_mark: | | | `camera_ui` | :white_check_mark: | |
| `clip` | :white_check_mark: | | | `clip` | :white_check_mark: | |
@@ -66,7 +66,6 @@ Only a subset of all [actions](../configuration/actions/README.md) are supported
| `fullscreen` | :heavy_multiplication_x: | Javascript does not support activating fullscreen without direct human interaction. Use `expand` as an alternative. | | `fullscreen` | :heavy_multiplication_x: | Javascript does not support activating fullscreen without direct human interaction. Use `expand` as an alternative. |
| `gallery` | :white_check_mark: | | | `gallery` | :white_check_mark: | |
| `image` | :white_check_mark: | | | `image` | :white_check_mark: | |
| `live_substream_select` | :white_check_mark: | |
| `live` | :white_check_mark: | | | `live` | :white_check_mark: | |
| `media` | :white_check_mark: | | | `media` | :white_check_mark: | |
| `media_player` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. | | `media_player` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. |
@@ -83,6 +82,8 @@ Only a subset of all [actions](../configuration/actions/README.md) are supported
| `ptz_controls` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. | | `ptz_controls` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. |
| `snapshot` | :white_check_mark: | | | `snapshot` | :white_check_mark: | |
| `snapshots` | :white_check_mark: | | | `snapshots` | :white_check_mark: | |
| `substream_off` | :white_check_mark: | The `camera` field is not exposed via URL; the selected camera is used. Use the action config directly to target another camera. |
| `substream_on` | :white_check_mark: | Pass the substream camera ID as the URL value to engage it directly; omit the value to cycle. |
## Examples ## Examples
@@ -1,14 +1,14 @@
import { GeneralActionConfig } from '../../../config/schema/actions/custom/general'; import { SubstreamOffActionConfig } from '../../../config/schema/actions/custom/substream-off';
import { CardActionsAPI } from '../../types'; import { CardActionsAPI } from '../../types';
import { SubstreamViewModifier } from '../../view/modifiers/substream'; import { SubstreamViewModifier } from '../../view/modifiers/substream';
import { AdvancedCameraCardAction } from './base'; import { AdvancedCameraCardAction } from './base';
export class SubstreamOffAction extends AdvancedCameraCardAction<GeneralActionConfig> { export class SubstreamOffAction extends AdvancedCameraCardAction<SubstreamOffActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); await super.execute(api);
api.getViewManager().setViewByParameters({ api.getViewManager().setViewByParameters({
modifiers: [new SubstreamViewModifier()], modifiers: [new SubstreamViewModifier({ camera: this._action.camera })],
}); });
} }
} }
@@ -1,11 +1,11 @@
import { CameraManager } from '../../../camera-manager/manager'; import { CameraManager } from '../../../camera-manager/manager';
import { SubstreamViewModifier } from '../../view/modifiers/substream'; import { SubstreamOnActionConfig } from '../../../config/schema/actions/custom/substream-on';
import { GeneralActionConfig } from '../../../config/schema/actions/custom/general';
import { View } from '../../../view/view'; import { View } from '../../../view/view';
import { CardActionsAPI } from '../../types'; import { CardActionsAPI } from '../../types';
import { SubstreamViewModifier } from '../../view/modifiers/substream';
import { AdvancedCameraCardAction } from './base'; import { AdvancedCameraCardAction } from './base';
export class SubstreamOnAction extends AdvancedCameraCardAction<GeneralActionConfig> { export class SubstreamOnAction extends AdvancedCameraCardAction<SubstreamOnActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> { public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api); await super.execute(api);
@@ -14,35 +14,38 @@ export class SubstreamOnAction extends AdvancedCameraCardAction<GeneralActionCon
return; return;
} }
const cameraID = this._action.camera ?? view.camera;
if (!cameraID) {
return;
}
const stream =
this._action.stream ??
this._getCycledSubstreamID(view, cameraID, api.getCameraManager());
api.getViewManager().setViewByParameters({ api.getViewManager().setViewByParameters({
modifiers: [ modifiers: [new SubstreamViewModifier({ stream, camera: cameraID })],
new SubstreamViewModifier(
this._getCycledSubstreamID(view, api.getCameraManager()),
),
],
}); });
} }
// The next substream in the selected camera's cycle: its `substream` // The next substream in the camera's cycle: its `substream` dependencies in
// dependencies in order, wrapping back round. `undefined` means the camera's // order, wrapping back round. `undefined` means the camera's own stream (no
// own stream (no substream). // substream).
private _getCycledSubstreamID( private _getCycledSubstreamID(
view: View, view: View,
cameraID: string,
cameraManager: CameraManager, cameraManager: CameraManager,
): string | undefined { ): string | undefined {
if (!view.camera) {
return undefined;
}
const dependencies = [ const dependencies = [
...cameraManager.getStore().getAllDependentCameras(view.camera, 'substream'), ...cameraManager.getStore().getAllDependentCameras(cameraID, 'substream'),
]; ];
if (dependencies.length <= 1) { if (dependencies.length <= 1) {
return undefined; return undefined;
} }
const current = view.context?.live?.overrides?.get(view.camera) ?? view.camera; const current = view.context?.live?.overrides?.get(cameraID) ?? cameraID;
const currentIndex = dependencies.indexOf(current); const currentIndex = dependencies.indexOf(current);
const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length; const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
// Index 0 is the camera itself, i.e. no substream. // Index 0 is the camera itself, i.e. no substream.
return dependencies[nextIndex] === view.camera ? undefined : dependencies[nextIndex]; return dependencies[nextIndex] === cameraID ? undefined : dependencies[nextIndex];
} }
} }
@@ -1,14 +0,0 @@
import { SubstreamSelectActionConfig } from '../../../config/schema/actions/custom/substream-select';
import { CardActionsAPI } from '../../types';
import { SubstreamViewModifier } from '../../view/modifiers/substream';
import { AdvancedCameraCardAction } from './base';
export class SubstreamSelectAction extends AdvancedCameraCardAction<SubstreamSelectActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
api.getViewManager().setViewByParameters({
modifiers: [new SubstreamViewModifier(this._action.camera)],
});
}
}
+2 -5
View File
@@ -43,7 +43,6 @@ import { SleepAction } from './actions/sleep';
import { StatusBarAction } from './actions/status-bar'; import { StatusBarAction } from './actions/status-bar';
import { SubstreamOffAction } from './actions/substream-off'; import { SubstreamOffAction } from './actions/substream-off';
import { SubstreamOnAction } from './actions/substream-on'; import { SubstreamOnAction } from './actions/substream-on';
import { SubstreamSelectAction } from './actions/substream-select';
import { ToggleAction } from './actions/toggle'; import { ToggleAction } from './actions/toggle';
import { UnmuteAction } from './actions/unmute'; import { UnmuteAction } from './actions/unmute';
import { URLAction } from './actions/url'; import { URLAction } from './actions/url';
@@ -130,11 +129,9 @@ export class ActionFactory {
return new CallEndAction(context, action, options?.config); return new CallEndAction(context, action, options?.config);
case 'camera_select': case 'camera_select':
return new CameraSelectAction(context, action, options?.config); return new CameraSelectAction(context, action, options?.config);
case 'live_substream_select': case 'substream_off':
return new SubstreamSelectAction(context, action, options?.config);
case 'live_substream_off':
return new SubstreamOffAction(context, action, options?.config); return new SubstreamOffAction(context, action, options?.config);
case 'live_substream_on': case 'substream_on':
return new SubstreamOnAction(context, action, options?.config); return new SubstreamOnAction(context, action, options?.config);
case 'media_player': case 'media_player':
return new MediaPlayerAction(context, action, options?.config); return new MediaPlayerAction(context, action, options?.config);
+6 -7
View File
@@ -134,7 +134,7 @@ export class CallManager {
...(needsNavigation && { ...(needsNavigation && {
params: { view: 'live', camera: parentID }, params: { view: 'live', camera: parentID },
}), }),
modifiers: [new SubstreamViewModifier(callCameraID, parentID)], modifiers: [new SubstreamViewModifier({ stream: callCameraID, camera: parentID })],
force: true, force: true,
}); });
this._api.getConditionStateManager().setState({ call: true }); this._api.getConditionStateManager().setState({ call: true });
@@ -255,12 +255,11 @@ export class CallManager {
const previousStream = getStreamCameraID(previousView, call.cameraID); const previousStream = getStreamCameraID(previousView, call.cameraID);
viewManager.setViewByParameters({ viewManager.setViewByParameters({
modifiers: [ modifiers: [
new SubstreamViewModifier( new SubstreamViewModifier({
previousStream && previousStream !== call.cameraID ...(previousStream &&
? previousStream previousStream !== call.cameraID && { stream: previousStream }),
: undefined, camera: call.cameraID,
call.cameraID, }),
),
], ],
force: true, force: true,
}); });
@@ -71,10 +71,7 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
actions: [ actions: [
cameraPriority === 'entity' cameraPriority === 'entity'
? // Set the currently selected camera to the state of the entity. ? // Set the currently selected camera to the state of the entity.
createCameraAction( createCameraAction(`{{ hass.states["${cameraControlEntity}"].state }}`)
'camera_select',
`{{ hass.states["${cameraControlEntity}"].state }}`,
)
: // Set the selected option in the entity to the current camera ID. : // Set the selected option in the entity to the current camera ID.
createInternalCallbackAction(async (api: CardActionsAPI) => { createInternalCallbackAction(async (api: CardActionsAPI) => {
const camera = api.getViewManager().getView()?.camera ?? undefined; const camera = api.getViewManager().getView()?.camera ?? undefined;
@@ -92,10 +89,7 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => {
], ],
actions: [ actions: [
// When the entity state changes, updated the selected option. // When the entity state changes, updated the selected option.
createCameraAction( createCameraAction('{{ advanced_camera_card.trigger.state.to }}'),
'camera_select',
'{{ advanced_camera_card.trigger.state.to }}',
),
], ],
tag: automationTag, tag: automationTag,
}, },
+2 -3
View File
@@ -22,9 +22,8 @@ const CALL_DISRUPTIVE_ACTIONS: ReadonlySet<string> = new Set([
'default', 'default',
// Substreams. // Substreams.
'live_substream_select', 'substream_on',
'live_substream_on', 'substream_off',
'live_substream_off',
// Stream-disrupting actions. `play` is intentionally NOT here: it's the // Stream-disrupting actions. `play` is intentionally NOT here: it's the
// recovery path from a paused state. // recovery path from a paused state.
+35 -11
View File
@@ -3,6 +3,8 @@ import { AdvancedCameraCardCustomActionConfig } from '../config/schema/actions/t
import { import {
createCameraAction, createCameraAction,
createGeneralAction, createGeneralAction,
createSubstreamOffAction,
createSubstreamOnAction,
createViewAction, createViewAction,
} from '../utils/action.js'; } from '../utils/action.js';
import { CardQueryStringAPI } from './types'; import { CardQueryStringAPI } from './types';
@@ -12,7 +14,12 @@ import { ViewParametersUserSpecified } from './view/types.js';
interface QueryStringViewIntent { interface QueryStringViewIntent {
view?: ViewParametersUserSpecified & { view?: ViewParametersUserSpecified & {
default?: boolean; default?: boolean;
substream?: string;
// The substream change to apply alongside the view. Tri-state:
// - `undefined`: no substream URL action present, no modifier issued.
// - `string`: `substream_on=X` — engage stream X.
// - `null`: `substream_off` — explicitly clear the override.
stream?: string | null;
}; };
other?: AdvancedCameraCardCustomActionConfig[]; other?: AdvancedCameraCardCustomActionConfig[];
} }
@@ -44,14 +51,20 @@ export class QueryStringManager {
private async _executeViewRelated(intent: QueryStringViewIntent): Promise<void> { private async _executeViewRelated(intent: QueryStringViewIntent): Promise<void> {
if (intent.view) { if (intent.view) {
const modifiers =
intent.view.stream !== undefined
? [
new SubstreamViewModifier(
intent.view.stream === null ? {} : { stream: intent.view.stream },
),
]
: undefined;
if (intent.view.default) { if (intent.view.default) {
await this._api.getViewManager().setViewDefaultWithNewQuery({ await this._api.getViewManager().setViewDefaultWithNewQuery({
params: { params: {
camera: intent.view.camera, camera: intent.view.camera,
}, },
...(intent.view.substream && { ...(modifiers && { modifiers }),
modifiers: [new SubstreamViewModifier(intent.view.substream)],
}),
}); });
} else { } else {
await this._api.getViewManager().setViewByParametersWithNewQuery({ await this._api.getViewManager().setViewByParametersWithNewQuery({
@@ -59,9 +72,7 @@ export class QueryStringManager {
...(intent.view.view && { view: intent.view.view }), ...(intent.view.view && { view: intent.view.view }),
...(intent.view.camera && { camera: intent.view.camera }), ...(intent.view.camera && { camera: intent.view.camera }),
}, },
...(intent.view.substream && { ...(modifiers && { modifiers }),
modifiers: [new SubstreamViewModifier(intent.view.substream)],
}),
}); });
} }
} }
@@ -84,8 +95,13 @@ export class QueryStringManager {
(result.view ??= {}).view = undefined; (result.view ??= {}).view = undefined;
} else if (action.advanced_camera_card_action === 'camera_select') { } else if (action.advanced_camera_card_action === 'camera_select') {
(result.view ??= {}).camera = action.camera; (result.view ??= {}).camera = action.camera;
} else if (action.advanced_camera_card_action === 'live_substream_select') { } else if (
(result.view ??= {}).substream = action.camera; action.advanced_camera_card_action === 'substream_on' &&
action.stream !== undefined
) {
(result.view ??= {}).stream = action.stream;
} else if (action.advanced_camera_card_action === 'substream_off') {
(result.view ??= {}).stream = null;
} else { } else {
(result.other ??= []).push(action); (result.other ??= []).push(action);
} }
@@ -116,11 +132,19 @@ export class QueryStringManager {
let action: AdvancedCameraCardCustomActionConfig | null = null; let action: AdvancedCameraCardCustomActionConfig | null = null;
switch (actionName) { switch (actionName) {
case 'camera_select': case 'camera_select':
case 'live_substream_select':
if (value) { if (value) {
action = createCameraAction(actionName, value, { cardID }); action = createCameraAction(value, { cardID });
} }
break; break;
case 'substream_on':
action = createSubstreamOnAction({
...(value && { stream: value }),
cardID,
});
break;
case 'substream_off':
action = createSubstreamOffAction({ cardID });
break;
case 'camera_ui': case 'camera_ui':
case 'default': case 'default':
case 'download': case 'download':
+21 -13
View File
@@ -1,30 +1,38 @@
import { View } from '../../../view/view'; import { View } from '../../../view/view';
import { ViewModifier } from '../types'; import { ViewModifier } from '../types';
// The single write path for the camera-keyed `live.overrides` map (the read interface SubstreamViewModifierOptions {
// path being `getStreamCameraID` in `view/substream`): sets a camera's // The substream to engage. When absent, the override is cleared so the
// substream override, or clears it when `substreamID` is absent so the // camera's own stream is used.
// camera's own stream is used. `cameraID` defaults to the selected camera. stream?: string;
export class SubstreamViewModifier implements ViewModifier {
private _substreamID?: string;
private _cameraID?: string;
constructor(substreamID?: string, cameraID?: string) { // The camera whose override to write. Defaults to the selected camera.
this._substreamID = substreamID; camera?: string;
this._cameraID = cameraID; }
// The single write path for the camera-keyed `live.overrides` map (the read
// path being `getStreamCameraID` in `view/substream`).
export class SubstreamViewModifier implements ViewModifier {
private _options: SubstreamViewModifierOptions;
constructor(options?: SubstreamViewModifierOptions) {
this._options = options ?? {};
} }
public modify(view: View): void { public modify(view: View): void {
const cameraID = this._cameraID ?? view.camera; const cameraID = this._options.camera ?? view.camera;
if (!cameraID) { if (!cameraID) {
return; return;
} }
if (!this._substreamID) { // A stream equal to the camera itself is semantically "no substream";
// normalise it to a cleared override so the map doesn't carry self-
// referential entries.
if (!this._options.stream || this._options.stream === cameraID) {
view.context?.live?.overrides?.delete(cameraID); view.context?.live?.overrides?.delete(cameraID);
return; return;
} }
const overrides = view.context?.live?.overrides ?? new Map<string, string>(); const overrides = view.context?.live?.overrides ?? new Map<string, string>();
overrides.set(cameraID, this._substreamID); overrides.set(cameraID, this._options.stream);
view.mergeInContext({ live: { overrides } }); view.mergeInContext({ live: { overrides } });
} }
} }
+15 -15
View File
@@ -27,6 +27,8 @@ import {
createPTZControlsAction, createPTZControlsAction,
createPTZMultiAction, createPTZMultiAction,
createSetReviewAction, createSetReviewAction,
createSubstreamOffAction,
createSubstreamOnAction,
createViewAction, createViewAction,
isAdvancedCameraCardCustomAction, isAdvancedCameraCardCustomAction,
} from '../utils/action'; } from '../utils/action';
@@ -166,7 +168,6 @@ export class MenuButtonController {
const menuCameraIDs = cameraManager.getStore().getCameraIDsWithCapability('menu'); const menuCameraIDs = cameraManager.getStore().getCameraIDsWithCapability('menu');
if (menuCameraIDs.size > 1) { if (menuCameraIDs.size > 1) {
const submenuItems = Array.from(menuCameraIDs, (cameraID) => { const submenuItems = Array.from(menuCameraIDs, (cameraID) => {
const action = createCameraAction('camera_select', cameraID);
const metadata = cameraManager.getCameraMetadata(cameraID); const metadata = cameraManager.getCameraMetadata(cameraID);
return { return {
@@ -176,7 +177,7 @@ export class MenuButtonController {
state_color: true, state_color: true,
title: metadata?.title, title: metadata?.title,
selected: view?.camera === cameraID, selected: view?.camera === cameraID,
...(action && { tap_action: action }), tap_action: createCameraAction(cameraID),
}; };
}); });
@@ -221,13 +222,12 @@ export class MenuButtonController {
title: localize('config.menu.buttons.substreams'), title: localize('config.menu.buttons.substreams'),
...config.menu.buttons.substreams, ...config.menu.buttons.substreams,
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
tap_action: createGeneralAction( tap_action: hasSubstream(view)
hasSubstream(view) ? 'live_substream_off' : 'live_substream_on', ? createSubstreamOffAction()
), : createSubstreamOnAction(),
}; };
} else if (streams.length > 2) { } else if (streams.length > 2) {
const menuItems = Array.from(streams, (streamID) => { const menuItems = Array.from(streams, (streamID) => {
const action = createCameraAction('live_substream_select', streamID);
const metadata = cameraManager.getCameraMetadata(streamID) ?? undefined; const metadata = cameraManager.getCameraMetadata(streamID) ?? undefined;
return { return {
enabled: true, enabled: true,
@@ -236,7 +236,7 @@ export class MenuButtonController {
state_color: true, state_color: true,
title: metadata?.title, title: metadata?.title,
selected: substreamAwareCameraID === streamID, selected: substreamAwareCameraID === streamID,
...(action && { tap_action: action }), tap_action: createSubstreamOnAction({ stream: streamID }),
}; };
}); });
@@ -538,10 +538,10 @@ export class MenuButtonController {
entity: metadata?.icon.entity, entity: metadata?.icon.entity,
state_color: true, state_color: true,
title: metadata?.title, title: metadata?.title,
tap_action: createCallStartAction( tap_action: createCallStartAction({
cameraID, camera: cameraID,
streamID === cameraID ? undefined : streamID, ...(streamID !== cameraID && { stream: streamID }),
), }),
}; };
}); });
@@ -676,8 +676,6 @@ export class MenuButtonController {
.map((playerEntityID) => { .map((playerEntityID) => {
const title = getEntityTitle(hass, playerEntityID) || playerEntityID; const title = getEntityTitle(hass, playerEntityID) || playerEntityID;
const state = hass.states[playerEntityID]; const state = hass.states[playerEntityID];
const playAction = createMediaPlayerAction(playerEntityID, 'play');
const stopAction = createMediaPlayerAction(playerEntityID, 'stop');
const disabled = !state || state.state === 'unavailable'; const disabled = !state || state.state === 'unavailable';
return { return {
@@ -687,8 +685,10 @@ export class MenuButtonController {
state_color: false, state_color: false,
title: title, title: title,
disabled: disabled, disabled: disabled,
...(!disabled && playAction && { tap_action: playAction }), ...(!disabled && {
...(!disabled && stopAction && { hold_action: stopAction }), tap_action: createMediaPlayerAction(playerEntityID, 'play'),
hold_action: createMediaPlayerAction(playerEntityID, 'stop'),
}),
}; };
}); });
+39
View File
@@ -827,6 +827,36 @@ const microphoneConnectedToCallTransform = (data: unknown): boolean => {
return true; return true;
}; };
// Unify the legacy trio `live_substream_{on,off,select}` into the new
// `substream_{on,off}` pair. `live_substream_select` carried the substream ID
// in its `camera` field; that field becomes `stream` on `substream_on`.
const substreamActionsUnifyTransform = (data: RawAdvancedCameraCardConfig): boolean => {
if (
data['action'] !== 'fire-dom-event' &&
data['action'] !== 'custom:advanced-camera-card-action'
) {
return false;
}
const action = data['advanced_camera_card_action'];
if (action === 'live_substream_on') {
data['advanced_camera_card_action'] = 'substream_on';
return true;
}
if (action === 'live_substream_off') {
data['advanced_camera_card_action'] = 'substream_off';
return true;
}
if (action === 'live_substream_select') {
data['advanced_camera_card_action'] = 'substream_on';
if ('camera' in data) {
data['stream'] = data['camera'];
delete data['camera'];
}
return true;
}
return false;
};
const frigateCardToAdvancedCameraCardStyleTransform = (data: unknown): unknown => { const frigateCardToAdvancedCameraCardStyleTransform = (data: unknown): unknown => {
if (typeof data !== 'object' || !data || Array.isArray(data)) { if (typeof data !== 'object' || !data || Array.isArray(data)) {
return data; return data;
@@ -1051,4 +1081,13 @@ const UPGRADES = [
typeof data === 'object' && data ? data[CONF_AUTOMATIONS] : {}, typeof data === 'object' && data ? data[CONF_AUTOMATIONS] : {},
); );
}, },
// Unify `live_substream_{on,off,select}` actions. Walked over the entire
// tree because card actions can appear anywhere (menu buttons, elements,
// automations, view-action handlers, etc.).
(data: unknown): boolean => {
return upgradeObjectRecursively(substreamActionsUnifyTransform)(
typeof data === 'object' && data ? (data as RawAdvancedCameraCardConfig) : {},
);
},
]; ];
@@ -8,8 +8,6 @@ const GENERAL_ACTIONS = [
'expand', 'expand',
'fullscreen', 'fullscreen',
'info', 'info',
'live_substream_off',
'live_substream_on',
'menu_toggle', 'menu_toggle',
'microphone_connect', 'microphone_connect',
'microphone_disconnect', 'microphone_disconnect',
@@ -0,0 +1,12 @@
import { z } from 'zod';
import { advancedCameraCardCustomActionsBaseSchema } from './base';
export const substreamOffActionConfigSchema =
advancedCameraCardCustomActionsBaseSchema.extend({
advanced_camera_card_action: z.literal('substream_off'),
// The camera whose substream override to clear. Defaults to the selected
// camera. A no-op if that camera has no substream engaged.
camera: z.string().optional(),
});
export type SubstreamOffActionConfig = z.infer<typeof substreamOffActionConfigSchema>;
@@ -0,0 +1,16 @@
import { z } from 'zod';
import { advancedCameraCardCustomActionsBaseSchema } from './base';
export const substreamOnActionConfigSchema =
advancedCameraCardCustomActionsBaseSchema.extend({
advanced_camera_card_action: z.literal('substream_on'),
// The camera that owns the substream. Defaults to the selected camera.
camera: z.string().optional(),
// The substream to engage: one of `camera`'s `substream` dependencies. When
// omitted, repeated calls advance through `camera`'s `substream`
// dependencies in order, wrapping back to no substream engaged.
stream: z.string().optional(),
});
export type SubstreamOnActionConfig = z.infer<typeof substreamOnActionConfigSchema>;
@@ -1,11 +0,0 @@
import { z } from 'zod';
import { advancedCameraCardCustomActionsBaseSchema } from './base';
export const substreamSelectActionConfigSchema =
advancedCameraCardCustomActionsBaseSchema.extend({
advanced_camera_card_action: z.literal('live_substream_select'),
camera: z.string(),
});
export type SubstreamSelectActionConfig = z.infer<
typeof substreamSelectActionConfigSchema
>;
+4 -2
View File
@@ -18,7 +18,8 @@ import { ptzDigitalActionConfigSchema } from './custom/ptz-digital';
import { ptzMultiActionSchema } from './custom/ptz-multi'; import { ptzMultiActionSchema } from './custom/ptz-multi';
import { setReviewActionConfigSchema } from './custom/set-review'; import { setReviewActionConfigSchema } from './custom/set-review';
import { sleepActionConfigSchema } from './custom/sleep'; import { sleepActionConfigSchema } from './custom/sleep';
import { substreamSelectActionConfigSchema } from './custom/substream-select'; import { substreamOffActionConfigSchema } from './custom/substream-off';
import { substreamOnActionConfigSchema } from './custom/substream-on';
import { viewActionConfigSchema } from './custom/view'; import { viewActionConfigSchema } from './custom/view';
import { stockActionSchema } from './stock/types'; import { stockActionSchema } from './stock/types';
@@ -76,7 +77,8 @@ const advancedCameraCardCustomActionSchema = z.union([
setReviewActionConfigSchema, setReviewActionConfigSchema,
sleepActionConfigSchema, sleepActionConfigSchema,
statusBarActionConfigSchema, statusBarActionConfigSchema,
substreamSelectActionConfigSchema, substreamOffActionConfigSchema,
substreamOnActionConfigSchema,
viewActionConfigSchema, viewActionConfigSchema,
viewDisplayModeActionConfigSchema, viewDisplayModeActionConfigSchema,
]); ]);
+36 -12
View File
@@ -27,7 +27,8 @@ import {
PTZActionPhase, PTZActionPhase,
} from '../config/schema/actions/custom/ptz.js'; } from '../config/schema/actions/custom/ptz.js';
import { SetReviewActionConfig } from '../config/schema/actions/custom/set-review.js'; import { SetReviewActionConfig } from '../config/schema/actions/custom/set-review.js';
import { SubstreamSelectActionConfig } from '../config/schema/actions/custom/substream-select.js'; import { SubstreamOffActionConfig } from '../config/schema/actions/custom/substream-off.js';
import { SubstreamOnActionConfig } from '../config/schema/actions/custom/substream-on.js';
import { ViewActionConfig } from '../config/schema/actions/custom/view.js'; import { ViewActionConfig } from '../config/schema/actions/custom/view.js';
import { PerformActionActionConfig } from '../config/schema/actions/stock/perform-action.js'; import { PerformActionActionConfig } from '../config/schema/actions/stock/perform-action.js';
import type { Notification } from '../config/schema/actions/types.js'; import type { Notification } from '../config/schema/actions/types.js';
@@ -72,20 +73,45 @@ export function createViewAction(
} }
export function createCameraAction( export function createCameraAction(
action: 'camera_select' | 'live_substream_select',
camera: string, camera: string,
options?: { options?: {
cardID?: string; cardID?: string;
}, },
): CameraSelectActionConfig | SubstreamSelectActionConfig { ): CameraSelectActionConfig {
return { return {
action: 'fire-dom-event', action: 'fire-dom-event',
advanced_camera_card_action: action, advanced_camera_card_action: 'camera_select',
camera: camera, camera: camera,
...(options?.cardID && { card_id: options.cardID }), ...(options?.cardID && { card_id: options.cardID }),
}; };
} }
export function createSubstreamOnAction(options?: {
stream?: string;
camera?: string;
cardID?: string;
}): SubstreamOnActionConfig {
return {
action: 'fire-dom-event',
advanced_camera_card_action: 'substream_on',
...(options?.stream && { stream: options.stream }),
...(options?.camera && { camera: options.camera }),
...(options?.cardID && { card_id: options.cardID }),
};
}
export function createSubstreamOffAction(options?: {
camera?: string;
cardID?: string;
}): SubstreamOffActionConfig {
return {
action: 'fire-dom-event',
advanced_camera_card_action: 'substream_off',
...(options?.camera && { camera: options.camera }),
...(options?.cardID && { card_id: options.cardID }),
};
}
export function createMediaPlayerAction( export function createMediaPlayerAction(
mediaPlayer: string, mediaPlayer: string,
mediaPlayerAction: 'play' | 'stop', mediaPlayerAction: 'play' | 'stop',
@@ -274,18 +300,16 @@ export function createSetReviewAction(reviewed?: boolean): SetReviewActionConfig
}; };
} }
export function createCallStartAction( export function createCallStartAction(options?: {
camera?: string, camera?: string;
stream?: string, stream?: string;
options?: {
cardID?: string; cardID?: string;
}, }): CallStartActionConfig {
): CallStartActionConfig {
return { return {
action: 'fire-dom-event', action: 'fire-dom-event',
advanced_camera_card_action: 'call_start', advanced_camera_card_action: 'call_start',
...(camera && { camera }), ...(options?.camera && { camera: options.camera }),
...(stream && { stream }), ...(options?.stream && { stream: options.stream }),
...(options?.cardID && { card_id: options.cardID }), ...(options?.cardID && { card_id: options.cardID }),
}; };
} }
@@ -1,21 +1,58 @@
import { expect, it } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { SubstreamOffAction } from '../../../../src/card-controller/actions/actions/substream-off'; import { SubstreamOffAction } from '../../../../src/card-controller/actions/actions/substream-off';
import { SubstreamViewModifier } from '../../../../src/card-controller/view/modifiers/substream'; import { applyViewModifiers } from '../../../../src/card-controller/view/modifiers';
import { createCardAPI } from '../../../test-utils'; import { createSubstreamOffAction } from '../../../../src/utils/action';
import { View } from '../../../../src/view/view';
import { createCardAPI, createView } from '../../../test-utils';
it('should handle live_substream_off action', async () => { // Runs the off-action for `view` and applies the modifier it produces.
const applySubstreamOff = async (
view: View,
options?: { camera?: string },
): Promise<void> => {
const api = createCardAPI(); const api = createCardAPI();
const action = new SubstreamOffAction( vi.mocked(api.getViewManager().getView).mockReturnValue(view);
{},
{ await new SubstreamOffAction({}, createSubstreamOffAction(options)).execute(api);
action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_off', const params = vi.mocked(api.getViewManager().setViewByParameters).mock.calls[0]?.[0];
applyViewModifiers(view, params?.modifiers);
};
describe('SubstreamOffAction', () => {
it('should clear the selected camera override', async () => {
const view = createView({
view: 'live',
camera: 'camera.office',
context: {
live: { overrides: new Map([['camera.office', 'camera.kitchen']]) },
}, },
});
await applySubstreamOff(view);
expect(view.context?.live?.overrides?.get('camera.office')).toBeUndefined();
});
it('should clear an explicit camera override', async () => {
const view = createView({
view: 'live',
camera: 'camera.driveway',
context: {
live: {
overrides: new Map([
['camera.office', 'camera.kitchen'],
['camera.driveway', 'camera.driveway_hd'],
]),
},
},
});
await applySubstreamOff(view, { camera: 'camera.office' });
expect(view.context?.live?.overrides?.get('camera.office')).toBeUndefined();
expect(view.context?.live?.overrides?.get('camera.driveway')).toBe(
'camera.driveway_hd',
); );
await action.execute(api);
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
modifiers: [expect.any(SubstreamViewModifier)],
}); });
}); });
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import { CameraManagerStore } from '../../../../src/camera-manager/store'; import { CameraManagerStore } from '../../../../src/camera-manager/store';
import { SubstreamOnAction } from '../../../../src/card-controller/actions/actions/substream-on'; import { SubstreamOnAction } from '../../../../src/card-controller/actions/actions/substream-on';
import { applyViewModifiers } from '../../../../src/card-controller/view/modifiers'; import { applyViewModifiers } from '../../../../src/card-controller/view/modifiers';
import { createSubstreamOnAction } from '../../../../src/utils/action';
import { getStreamCameraID } from '../../../../src/view/substream'; import { getStreamCameraID } from '../../../../src/view/substream';
import { View } from '../../../../src/view/view'; import { View } from '../../../../src/view/view';
import { import {
@@ -13,15 +14,6 @@ import {
createView, createView,
} from '../../../test-utils'; } from '../../../test-utils';
const createAction = (): SubstreamOnAction =>
new SubstreamOnAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_on',
},
);
// A store where `camera.office` has one substream dependency, `camera.kitchen`. // A store where `camera.office` has one substream dependency, `camera.kitchen`.
const createStoreWithSubstreams = (): CameraManagerStore => const createStoreWithSubstreams = (): CameraManagerStore =>
createStore([ createStore([
@@ -37,28 +29,40 @@ const createStoreWithSubstreams = (): CameraManagerStore =>
]); ]);
// Runs the on-action for `view`, applies the modifier it produces (via the real // Runs the on-action for `view`, applies the modifier it produces (via the real
// `applyViewModifiers`), and returns the resulting engaged stream. // `applyViewModifiers`), and returns the engaged stream for the override key
const getStreamAfterSubstreamOn = async ( // the modifier wrote (`camera` if set, otherwise the selected camera).
const applySubstreamOn = async (
view: View, view: View,
store: CameraManagerStore = createStoreWithSubstreams(), options?: {
store?: CameraManagerStore;
camera?: string;
stream?: string;
},
): Promise<string | null> => { ): Promise<string | null> => {
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(view); vi.mocked(api.getViewManager().getView).mockReturnValue(view);
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store)); vi.mocked(api.getCameraManager).mockReturnValue(
createCameraManager(options?.store ?? createStoreWithSubstreams()),
);
await createAction().execute(api); await new SubstreamOnAction(
{},
createSubstreamOnAction({
camera: options?.camera,
stream: options?.stream,
}),
).execute(api);
const params = vi.mocked(api.getViewManager().setViewByParameters).mock.calls[0]?.[0]; const params = vi.mocked(api.getViewManager().setViewByParameters).mock.calls[0]?.[0];
applyViewModifiers(view, params?.modifiers); applyViewModifiers(view, params?.modifiers);
return getStreamCameraID(view); return getStreamCameraID(view, options?.camera);
}; };
describe('SubstreamOnAction', () => { describe('SubstreamOnAction', () => {
describe('cycling (no `stream` parameter)', () => {
it('should advance to the next dependency', async () => { it('should advance to the next dependency', async () => {
expect( expect(
await getStreamAfterSubstreamOn( await applySubstreamOn(createView({ view: 'live', camera: 'camera.office' })),
createView({ view: 'live', camera: 'camera.office' }),
),
).toBe('camera.kitchen'); ).toBe('camera.kitchen');
}); });
@@ -71,7 +75,7 @@ describe('SubstreamOnAction', () => {
}, },
}); });
expect(await getStreamAfterSubstreamOn(view)).toBe('camera.office'); expect(await applySubstreamOn(view)).toBe('camera.office');
}); });
it('should treat a malformed override as the start of the cycle', async () => { it('should treat a malformed override as the start of the cycle', async () => {
@@ -83,18 +87,90 @@ describe('SubstreamOnAction', () => {
}, },
}); });
expect(await getStreamAfterSubstreamOn(view)).toBe('camera.office'); expect(await applySubstreamOn(view)).toBe('camera.office');
}); });
it('should engage no substream when there are no usable dependencies', async () => { it('should engage no substream when there are no usable dependencies', async () => {
const view = createView({ view: 'live', camera: 'camera.office' }); const view = createView({ view: 'live', camera: 'camera.office' });
expect(await getStreamAfterSubstreamOn(view, createStore())).toBe('camera.office'); expect(await applySubstreamOn(view, { store: createStore() })).toBe(
'camera.office',
);
});
}); });
it('should engage no substream when the view has no camera', async () => { describe('with an explicit `stream`', () => {
it('should engage that stream on the selected camera', async () => {
expect( expect(
await getStreamAfterSubstreamOn(createView({ view: 'live', camera: null })), await applySubstreamOn(createView({ view: 'live', camera: 'camera.office' }), {
stream: 'camera.kitchen',
}),
).toBe('camera.kitchen');
});
it('should treat `stream` equal to the camera as no substream', async () => {
// Already cycled to a substream; passing the parent camera as `stream`
// should be equivalent to off.
const view = createView({
view: 'live',
camera: 'camera.office',
context: {
live: { overrides: new Map([['camera.office', 'camera.kitchen']]) },
},
});
expect(await applySubstreamOn(view, { stream: 'camera.office' })).toBe(
'camera.office',
);
});
});
describe('with an explicit `camera`', () => {
it('should target that camera instead of the selected one', async () => {
const view = createView({ view: 'live', camera: 'camera.driveway' });
expect(
await applySubstreamOn(view, {
camera: 'camera.office',
stream: 'camera.kitchen',
}),
).toBe('camera.kitchen');
});
it('should cycle dependencies of the explicit camera', async () => {
const view = createView({ view: 'live', camera: 'camera.driveway' });
expect(await applySubstreamOn(view, { camera: 'camera.office' })).toBe(
'camera.kitchen',
);
});
it('should engage no substream when the explicit camera has no dependencies', async () => {
const view = createView({
view: 'live',
camera: 'camera.driveway',
context: {
live: { overrides: new Map([['camera.no_deps', 'camera.stale']]) },
},
});
expect(
await applySubstreamOn(view, {
camera: 'camera.no_deps',
store: createStore([
{
cameraID: 'camera.no_deps',
capabilities: createCapabilities({ substream: true }),
},
]),
}),
).toBe('camera.no_deps');
});
});
it('should engage no substream when the view has no camera and the action has no camera', async () => {
expect(
await applySubstreamOn(createView({ view: 'live', camera: null })),
).toBeNull(); ).toBeNull();
}); });
@@ -102,7 +178,7 @@ describe('SubstreamOnAction', () => {
const api = createCardAPI(); const api = createCardAPI();
vi.mocked(api.getViewManager().getView).mockReturnValue(null); vi.mocked(api.getViewManager().getView).mockReturnValue(null);
await createAction().execute(api); await new SubstreamOnAction({}, createSubstreamOnAction()).execute(api);
expect(api.getViewManager().setViewByParameters).not.toBeCalled(); expect(api.getViewManager().setViewByParameters).not.toBeCalled();
}); });
@@ -1,22 +0,0 @@
import { expect, it } from 'vitest';
import { SubstreamSelectAction } from '../../../../src/card-controller/actions/actions/substream-select';
import { SubstreamViewModifier } from '../../../../src/card-controller/view/modifiers/substream';
import { createCardAPI } from '../../../test-utils';
it('should handle live_substream_select action', async () => {
const api = createCardAPI();
const action = new SubstreamSelectAction(
{},
{
action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_select',
camera: 'substream',
},
);
await action.execute(api);
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
modifiers: [expect.any(SubstreamViewModifier)],
});
});
+2 -13
View File
@@ -40,7 +40,6 @@ import { SleepAction } from '../../../src/card-controller/actions/actions/sleep'
import { StatusBarAction } from '../../../src/card-controller/actions/actions/status-bar'; import { StatusBarAction } from '../../../src/card-controller/actions/actions/status-bar';
import { SubstreamOffAction } from '../../../src/card-controller/actions/actions/substream-off'; import { SubstreamOffAction } from '../../../src/card-controller/actions/actions/substream-off';
import { SubstreamOnAction } from '../../../src/card-controller/actions/actions/substream-on'; import { SubstreamOnAction } from '../../../src/card-controller/actions/actions/substream-on';
import { SubstreamSelectAction } from '../../../src/card-controller/actions/actions/substream-select';
import { ToggleAction } from '../../../src/card-controller/actions/actions/toggle'; import { ToggleAction } from '../../../src/card-controller/actions/actions/toggle';
import { UnmuteAction } from '../../../src/card-controller/actions/actions/unmute'; import { UnmuteAction } from '../../../src/card-controller/actions/actions/unmute';
import { URLAction } from '../../../src/card-controller/actions/actions/url'; import { URLAction } from '../../../src/card-controller/actions/actions/url';
@@ -111,18 +110,8 @@ describe('ActionFactory', () => {
[{ advanced_camera_card_action: 'fullscreen' as const }, FullscreenAction], [{ advanced_camera_card_action: 'fullscreen' as const }, FullscreenAction],
[{ advanced_camera_card_action: 'info' as const }, InfoAction], [{ advanced_camera_card_action: 'info' as const }, InfoAction],
[{ advanced_camera_card_action: 'image' as const }, ViewAction], [{ advanced_camera_card_action: 'image' as const }, ViewAction],
[ [{ advanced_camera_card_action: 'substream_off' as const }, SubstreamOffAction],
{ advanced_camera_card_action: 'live_substream_off' as const }, [{ advanced_camera_card_action: 'substream_on' as const }, SubstreamOnAction],
SubstreamOffAction,
],
[{ advanced_camera_card_action: 'live_substream_on' as const }, SubstreamOnAction],
[
{
advanced_camera_card_action: 'live_substream_select' as const,
camera: 'camera.office',
},
SubstreamSelectAction,
],
[{ advanced_camera_card_action: 'live' as const }, ViewAction], [{ advanced_camera_card_action: 'live' as const }, ViewAction],
[ [
{ {
+6 -4
View File
@@ -7,6 +7,8 @@ import {
createGeneralAction, createGeneralAction,
createLogAction, createLogAction,
createMediaPlayerAction, createMediaPlayerAction,
createSubstreamOffAction,
createSubstreamOnAction,
createViewAction, createViewAction,
} from '../../../src/utils/action'; } from '../../../src/utils/action';
import { createCardAPI, createConfig } from '../../test-utils'; import { createCardAPI, createConfig } from '../../test-utils';
@@ -77,10 +79,10 @@ describe('LockManager', () => {
for (const action of [ for (const action of [
createViewAction('clips'), createViewAction('clips'),
createCameraAction('camera_select', 'cam-1'), createCameraAction('cam-1'),
createCameraAction('live_substream_select', 'cam-1'), createSubstreamOnAction({ stream: 'cam-1' }),
createGeneralAction('live_substream_on'), createSubstreamOnAction(),
createGeneralAction('live_substream_off'), createSubstreamOffAction(),
createGeneralAction('default'), createGeneralAction('default'),
createGeneralAction('pause'), createGeneralAction('pause'),
createGeneralAction('reload'), createGeneralAction('reload'),
@@ -140,10 +140,8 @@ describe('QueryStringManager', () => {
expect(api.getViewManager().setViewDefault).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled();
}); });
it('should execute live_substream_select action', async () => { it('should execute substream_on with a stream value as a view modifier', async () => {
setQueryString( setQueryString('?advanced-camera-card-action.id.substream_on=camera.office_hd');
'?advanced-camera-card-action.id.live_substream_select=camera.office_hd',
);
const api = createCardAPI(); const api = createCardAPI();
setCardID(api, 'id'); setCardID(api, 'id');
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
@@ -154,7 +152,7 @@ describe('QueryStringManager', () => {
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy(); expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
modifiers: [expect.any(SubstreamViewModifier)], modifiers: [new SubstreamViewModifier({ stream: 'camera.office_hd' })],
params: {}, params: {},
}); });
@@ -162,11 +160,66 @@ describe('QueryStringManager', () => {
expect(api.getViewManager().setViewDefault).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled();
}); });
describe('should ignore action without value', () => { it('should dispatch substream_on without a value as a non-view action', async () => {
it.each([['camera_select' as const], ['live_substream_select' as const]])( setQueryString('?advanced-camera-card-action.id.substream_on=');
'%s', const api = createCardAPI();
async (action: string) => { setCardID(api, 'id');
setQueryString(`?advanced-camera-card-action.id.${action}=`); vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
const manager = new QueryStringManager(api);
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
await manager.executeIfNecessary();
expect(api.getActionsManager().executeActions).toBeCalledWith({
actions: [
{
action: 'fire-dom-event',
card_id: 'id',
advanced_camera_card_action: 'substream_on',
},
],
});
});
it('should execute substream_off as a view modifier that clears the override', async () => {
setQueryString('?advanced-camera-card-action.id.substream_off=');
const api = createCardAPI();
setCardID(api, 'id');
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
const manager = new QueryStringManager(api);
expect(manager.hasViewRelatedActionsToRun()).toBeTruthy();
await manager.executeIfNecessary();
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
modifiers: [new SubstreamViewModifier()],
params: {},
});
expect(api.getActionsManager().executeActions).not.toBeCalled();
});
it('should warn on the legacy live_substream_select URL form', async () => {
const consoleSpy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
setQueryString(
'?advanced-camera-card-action.id.live_substream_select=camera.office_hd',
);
const api = createCardAPI();
setCardID(api, 'id');
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
const manager = new QueryStringManager(api);
expect(manager.hasViewRelatedActionsToRun()).toBeFalsy();
await manager.executeIfNecessary();
expect(api.getActionsManager().executeActions).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
expect(consoleSpy).toBeCalledWith(expect.stringContaining('live_substream_select'));
});
it('should ignore camera_select without a value', async () => {
setQueryString('?advanced-camera-card-action.id.camera_select=');
const api = createCardAPI(); const api = createCardAPI();
setCardID(api, 'id'); setCardID(api, 'id');
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
@@ -178,8 +231,6 @@ describe('QueryStringManager', () => {
expect(api.getActionsManager().executeActions).not.toBeCalled(); expect(api.getActionsManager().executeActions).not.toBeCalled();
expect(api.getViewManager().setViewDefault).not.toBeCalled(); expect(api.getViewManager().setViewDefault).not.toBeCalled();
expect(api.getViewManager().setViewByParameters).not.toBeCalled(); expect(api.getViewManager().setViewByParameters).not.toBeCalled();
},
);
}); });
it('should handle unknown action', async () => { it('should handle unknown action', async () => {
@@ -235,7 +286,7 @@ describe('QueryStringManager', () => {
it('should handle view and default with camera and substream specified', async () => { it('should handle view and default with camera and substream specified', async () => {
setQueryString( setQueryString(
'?advanced-camera-card-action.id.clips=' + '?advanced-camera-card-action.id.clips=' +
'&advanced-camera-card-action.id.live_substream_select=camera.kitchen_hd' + '&advanced-camera-card-action.id.substream_on=camera.kitchen_hd' +
'&advanced-camera-card-action.id.default=' + '&advanced-camera-card-action.id.default=' +
'&advanced-camera-card-action.id.camera_select=camera.kitchen', '&advanced-camera-card-action.id.camera_select=camera.kitchen',
); );
@@ -250,7 +301,7 @@ describe('QueryStringManager', () => {
params: { params: {
camera: 'camera.kitchen', camera: 'camera.kitchen',
}, },
modifiers: [expect.any(SubstreamViewModifier)], modifiers: [new SubstreamViewModifier({ stream: 'camera.kitchen_hd' })],
}); });
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
}); });
@@ -276,9 +327,7 @@ describe('QueryStringManager', () => {
}); });
it('should only execute when needed', async () => { it('should only execute when needed', async () => {
setQueryString( setQueryString('?advanced-camera-card-action.id.substream_on=camera.office_hd');
'?advanced-camera-card-action.id.live_substream_select=camera.office_hd',
);
const api = createCardAPI(); const api = createCardAPI();
setCardID(api, 'id'); setCardID(api, 'id');
vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true);
@@ -6,7 +6,7 @@ describe('SubstreamViewModifier', () => {
it('should write the override for the selected camera', () => { it('should write the override for the selected camera', () => {
const view = createView({ camera: 'camera' }); const view = createView({ camera: 'camera' });
new SubstreamViewModifier('substream').modify(view); new SubstreamViewModifier({ stream: 'substream' }).modify(view);
expect(view.context?.live?.overrides?.get('camera')).toBe('substream'); expect(view.context?.live?.overrides?.get('camera')).toBe('substream');
}); });
@@ -28,7 +28,10 @@ describe('SubstreamViewModifier', () => {
context: { live: { overrides: new Map([['camera', 'substream']]) } }, context: { live: { overrides: new Map([['camera', 'substream']]) } },
}); });
new SubstreamViewModifier('other-substream', 'other-camera').modify(view); new SubstreamViewModifier({
stream: 'other-substream',
camera: 'other-camera',
}).modify(view);
expect(view.context?.live?.overrides?.get('other-camera')).toBe('other-substream'); expect(view.context?.live?.overrides?.get('other-camera')).toBe('other-substream');
expect(view.context?.live?.overrides?.get('camera')).toBe('substream'); expect(view.context?.live?.overrides?.get('camera')).toBe('substream');
@@ -40,15 +43,26 @@ describe('SubstreamViewModifier', () => {
context: { live: { overrides: new Map([['other-camera', 'other-substream']]) } }, context: { live: { overrides: new Map([['other-camera', 'other-substream']]) } },
}); });
new SubstreamViewModifier(undefined, 'other-camera').modify(view); new SubstreamViewModifier({ camera: 'other-camera' }).modify(view);
expect(view.context?.live?.overrides?.get('other-camera')).toBeUndefined(); expect(view.context?.live?.overrides?.get('other-camera')).toBeUndefined();
}); });
it('should clear the override when the stream equals the camera itself', () => {
const view = createView({
camera: 'camera',
context: { live: { overrides: new Map([['camera', 'substream']]) } },
});
new SubstreamViewModifier({ stream: 'camera' }).modify(view);
expect(view.context?.live?.overrides?.get('camera')).toBeUndefined();
});
it('should no-op for a view without a camera', () => { it('should no-op for a view without a camera', () => {
const view = createView({ camera: null }); const view = createView({ camera: null });
new SubstreamViewModifier('substream').modify(view); new SubstreamViewModifier({ stream: 'substream' }).modify(view);
expect(view.context).toBeNull(); expect(view.context).toBeNull();
}); });
@@ -278,7 +278,7 @@ describe('MenuButtonController', () => {
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
tap_action: { tap_action: {
action: 'fire-dom-event', action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_on', advanced_camera_card_action: 'substream_on',
}, },
}); });
}); });
@@ -322,7 +322,7 @@ describe('MenuButtonController', () => {
type: 'custom:advanced-camera-card-menu-icon', type: 'custom:advanced-camera-card-menu-icon',
tap_action: { tap_action: {
action: 'fire-dom-event', action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_off', advanced_camera_card_action: 'substream_off',
}, },
}); });
}); });
@@ -394,8 +394,8 @@ describe('MenuButtonController', () => {
selected: true, selected: true,
tap_action: { tap_action: {
action: 'fire-dom-event', action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_select', advanced_camera_card_action: 'substream_on',
camera: 'camera-1', stream: 'camera-1',
}, },
}, },
{ {
@@ -407,8 +407,8 @@ describe('MenuButtonController', () => {
selected: false, selected: false,
tap_action: { tap_action: {
action: 'fire-dom-event', action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_select', advanced_camera_card_action: 'substream_on',
camera: 'camera-2', stream: 'camera-2',
}, },
}, },
{ {
@@ -420,8 +420,8 @@ describe('MenuButtonController', () => {
selected: false, selected: false,
tap_action: { tap_action: {
action: 'fire-dom-event', action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_select', advanced_camera_card_action: 'substream_on',
camera: 'camera-3', stream: 'camera-3',
}, },
}, },
], ],
@@ -489,8 +489,8 @@ describe('MenuButtonController', () => {
selected: false, selected: false,
tap_action: { tap_action: {
action: 'fire-dom-event', action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_select', advanced_camera_card_action: 'substream_on',
camera: 'camera-1', stream: 'camera-1',
}, },
}, },
{ {
@@ -504,8 +504,8 @@ describe('MenuButtonController', () => {
selected: true, selected: true,
tap_action: { tap_action: {
action: 'fire-dom-event', action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_select', advanced_camera_card_action: 'substream_on',
camera: 'camera-2', stream: 'camera-2',
}, },
}, },
{ {
@@ -517,8 +517,8 @@ describe('MenuButtonController', () => {
selected: false, selected: false,
tap_action: { tap_action: {
action: 'fire-dom-event', action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_select', advanced_camera_card_action: 'substream_on',
camera: 'camera-3', stream: 'camera-3',
}, },
}, },
], ],
+229 -10
View File
@@ -1897,7 +1897,7 @@ describe('should handle version specific upgrades', () => {
actions: [ actions: [
{ {
action: 'custom:advanced-camera-card-action' as const, action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'live_substream_on' as const, advanced_camera_card_action: 'substream_on' as const,
}, },
], ],
}, },
@@ -1919,7 +1919,7 @@ describe('should handle version specific upgrades', () => {
actions: [ actions: [
{ {
action: 'custom:advanced-camera-card-action' as const, action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'live_substream_on' as const, advanced_camera_card_action: 'substream_on' as const,
}, },
], ],
}, },
@@ -2023,7 +2023,7 @@ describe('should handle version specific upgrades', () => {
actions: [ actions: [
{ {
action: 'custom:advanced-camera-card-action' as const, action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'live_substream_on' as const, advanced_camera_card_action: 'substream_on' as const,
}, },
], ],
}, },
@@ -2045,7 +2045,7 @@ describe('should handle version specific upgrades', () => {
actions: [ actions: [
{ {
action: 'custom:advanced-camera-card-action' as const, action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'live_substream_on' as const, advanced_camera_card_action: 'substream_on' as const,
}, },
], ],
}, },
@@ -2154,7 +2154,7 @@ describe('should handle version specific upgrades', () => {
actions: [ actions: [
{ {
action: 'custom:advanced-camera-card-action' as const, action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'live_substream_on' as const, advanced_camera_card_action: 'substream_on' as const,
}, },
], ],
}, },
@@ -2176,7 +2176,7 @@ describe('should handle version specific upgrades', () => {
actions: [ actions: [
{ {
action: 'custom:advanced-camera-card-action' as const, action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'live_substream_on' as const, advanced_camera_card_action: 'substream_on' as const,
}, },
], ],
}, },
@@ -2323,7 +2323,7 @@ describe('should handle version specific upgrades', () => {
actions: [ actions: [
{ {
action: 'custom:advanced-camera-card-action' as const, action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'live_substream_on' as const, advanced_camera_card_action: 'substream_on' as const,
}, },
], ],
}, },
@@ -2351,7 +2351,7 @@ describe('should handle version specific upgrades', () => {
actions: [ actions: [
{ {
action: 'custom:advanced-camera-card-action' as const, action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'live_substream_on' as const, advanced_camera_card_action: 'substream_on' as const,
}, },
], ],
}, },
@@ -2455,7 +2455,7 @@ describe('should handle version specific upgrades', () => {
actions: [ actions: [
{ {
action: 'custom:advanced-camera-card-action' as const, action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'live_substream_on' as const, advanced_camera_card_action: 'substream_on' as const,
}, },
], ],
}, },
@@ -2477,7 +2477,7 @@ describe('should handle version specific upgrades', () => {
actions: [ actions: [
{ {
action: 'custom:advanced-camera-card-action' as const, action: 'custom:advanced-camera-card-action' as const,
advanced_camera_card_action: 'live_substream_on' as const, advanced_camera_card_action: 'substream_on' as const,
}, },
], ],
}, },
@@ -4051,5 +4051,224 @@ describe('should handle version specific upgrades', () => {
postUpgradeChecks(config); postUpgradeChecks(config);
}); });
}); });
describe('live_substream_{on,off,select} → substream_{on,off}', () => {
it('rewrites live_substream_on to substream_on in an automation', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
automations: [
{
conditions: [{ condition: 'initialized' }],
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_on',
},
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.automations[0].actions).toEqual([
{
action: 'fire-dom-event',
advanced_camera_card_action: 'substream_on',
},
]);
postUpgradeChecks(config);
});
it('rewrites live_substream_off to substream_off in an automation', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
automations: [
{
conditions: [{ condition: 'initialized' }],
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_off',
},
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.automations[0].actions).toEqual([
{
action: 'fire-dom-event',
advanced_camera_card_action: 'substream_off',
},
]);
postUpgradeChecks(config);
});
it('rewrites live_substream_select to substream_on with camera → stream', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
automations: [
{
conditions: [{ condition: 'initialized' }],
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_select',
camera: 'camera.office_hd',
},
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.automations[0].actions).toEqual([
{
action: 'fire-dom-event',
advanced_camera_card_action: 'substream_on',
stream: 'camera.office_hd',
},
]);
postUpgradeChecks(config);
});
it('rewrites a malformed live_substream_select with no camera field', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
automations: [
{
conditions: [{ condition: 'initialized' }],
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_select',
},
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.automations[0].actions).toEqual([
{
action: 'fire-dom-event',
advanced_camera_card_action: 'substream_on',
},
]);
postUpgradeChecks(config);
});
it('migrates actions on elements and overrides', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
elements: [
{
type: 'custom:advanced-camera-card-menu-icon',
icon: 'mdi:video-input-component',
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_select',
camera: 'camera.office_hd',
},
},
],
overrides: [
{
conditions: [{ condition: 'fullscreen', fullscreen: true }],
merge: {
menu: {
buttons: {
substreams: {
tap_action: {
action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_on',
},
},
},
},
},
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.elements[0].tap_action).toEqual({
action: 'fire-dom-event',
advanced_camera_card_action: 'substream_on',
stream: 'camera.office_hd',
});
expect(config.overrides[0].merge.menu.buttons.substreams.tap_action).toEqual({
action: 'fire-dom-event',
advanced_camera_card_action: 'substream_on',
});
postUpgradeChecks(config);
});
it('leaves opaque user payloads alone', () => {
// A perform-action service payload that happens to mention the legacy
// action string in its `data` block must not be rewritten — `data` is
// opaque, not an action.
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
automations: [
{
conditions: [{ condition: 'initialized' }],
actions: [
{
action: 'perform-action',
perform_action: 'script.bogus',
data: {
advanced_camera_card_action: 'live_substream_select',
camera: 'camera.office_hd',
},
},
],
},
],
};
expect(upgradeConfig(config)).toBeFalsy();
expect(config.automations[0].actions[0]).toEqual({
action: 'perform-action',
perform_action: 'script.bogus',
data: {
advanced_camera_card_action: 'live_substream_select',
camera: 'camera.office_hd',
},
});
});
it('is idempotent', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
automations: [
{
conditions: [{ condition: 'initialized' }],
actions: [
{
action: 'fire-dom-event',
advanced_camera_card_action: 'live_substream_select',
camera: 'camera.office_hd',
},
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
// Running upgradeConfig again should not change anything.
expect(upgradeConfig(config)).toBeFalsy();
expect(config.automations[0].actions).toEqual([
{
action: 'fire-dom-event',
advanced_camera_card_action: 'substream_on',
stream: 'camera.office_hd',
},
]);
postUpgradeChecks(config);
});
});
}); });
}); });
+4 -7
View File
@@ -1054,16 +1054,13 @@ describe('config defaults', () => {
}, },
{ {
action: 'custom:advanced-camera-card-action', action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'live_substream_off', advanced_camera_card_action: 'substream_off',
}, },
{ {
action: 'custom:advanced-camera-card-action', action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'live_substream_on', advanced_camera_card_action: 'substream_on',
}, camera: 'camera.front_door',
{ stream: 'camera.front_door_hd',
action: 'custom:advanced-camera-card-action',
advanced_camera_card_action: 'live_substream_select',
camera: 'camera.front_door_hd',
}, },
{ {
action: 'custom:advanced-camera-card-action', action: 'custom:advanced-camera-card-action',
+52 -5
View File
@@ -20,6 +20,8 @@ import {
createPTZMultiAction, createPTZMultiAction,
createSelectOptionAction, createSelectOptionAction,
createSetReviewAction, createSetReviewAction,
createSubstreamOffAction,
createSubstreamOnAction,
createViewAction, createViewAction,
getActionConfigGivenAction, getActionConfigGivenAction,
hasAction, hasAction,
@@ -70,14 +72,12 @@ describe('createViewAction', () => {
describe('createCameraAction', () => { describe('createCameraAction', () => {
it('should create camera_select action', () => { it('should create camera_select action', () => {
expect(createCameraAction('camera_select', 'camera', { cardID: 'card_id' })).toEqual( expect(createCameraAction('camera', { cardID: 'card_id' })).toEqual({
{
action: 'fire-dom-event', action: 'fire-dom-event',
camera: 'camera', camera: 'camera',
advanced_camera_card_action: 'camera_select', advanced_camera_card_action: 'camera_select',
card_id: 'card_id', card_id: 'card_id',
}, });
);
}); });
}); });
@@ -370,7 +370,9 @@ describe('createCallStartAction', () => {
it('should create call start action with a camera, stream and cardID', () => { it('should create call start action with a camera, stream and cardID', () => {
expect( expect(
createCallStartAction('camera.front', 'camera.front_doorbell', { createCallStartAction({
camera: 'camera.front',
stream: 'camera.front_doorbell',
cardID: 'card_id', cardID: 'card_id',
}), }),
).toEqual({ ).toEqual({
@@ -383,6 +385,51 @@ describe('createCallStartAction', () => {
}); });
}); });
describe('createSubstreamOnAction', () => {
it('should create substream_on action without options', () => {
expect(createSubstreamOnAction()).toEqual({
action: 'fire-dom-event',
advanced_camera_card_action: 'substream_on',
});
});
it('should create substream_on action with camera, stream, and cardID', () => {
expect(
createSubstreamOnAction({
camera: 'camera.front',
stream: 'camera.front_hd',
cardID: 'card_id',
}),
).toEqual({
action: 'fire-dom-event',
advanced_camera_card_action: 'substream_on',
camera: 'camera.front',
stream: 'camera.front_hd',
card_id: 'card_id',
});
});
});
describe('createSubstreamOffAction', () => {
it('should create substream_off action without options', () => {
expect(createSubstreamOffAction()).toEqual({
action: 'fire-dom-event',
advanced_camera_card_action: 'substream_off',
});
});
it('should create substream_off action with camera and cardID', () => {
expect(
createSubstreamOffAction({ camera: 'camera.front', cardID: 'card_id' }),
).toEqual({
action: 'fire-dom-event',
advanced_camera_card_action: 'substream_off',
camera: 'camera.front',
card_id: 'card_id',
});
});
});
describe('createCallEndAction', () => { describe('createCallEndAction', () => {
it('should create call end action', () => { it('should create call end action', () => {
expect(createCallEndAction()).toEqual({ expect(createCallEndAction()).toEqual({