diff --git a/docs/configuration/actions/custom/README.md b/docs/configuration/actions/custom/README.md index 3a910f98..d789440f 100644 --- a/docs/configuration/actions/custom/README.md +++ b/docs/configuration/actions/custom/README.md @@ -228,50 +228,6 @@ action: custom:advanced-camera-card-action 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` 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. +## `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` Change to the `timeline` view. @@ -865,20 +858,20 @@ elements: title: Turn off substream tap_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 icon: mdi:alpha-o-circle - title: Turn on substream + title: Cycle substream tap_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 icon: mdi:alpha-n-circle title: Select HD substream tap_action: action: custom:advanced-camera-card-action - advanced_camera_card_action: live_substream_select - camera: camera.front_door_hd + advanced_camera_card_action: substream_on + stream: camera.front_door_hd - type: custom:advanced-camera-card-menu-icon icon: mdi:alpha-o-circle title: Log to console diff --git a/docs/configuration/automations.md b/docs/configuration/automations.md index a5f415b8..79143258 100644 --- a/docs/configuration/automations.md +++ b/docs/configuration/automations.md @@ -32,8 +32,8 @@ automations: fullscreen: true actions: - action: custom:advanced-camera-card-action - advanced_camera_card_action: live_substream_on + advanced_camera_card_action: substream_on actions_not: - action: custom:advanced-camera-card-action - advanced_camera_card_action: live_substream_off + advanced_camera_card_action: substream_off ``` diff --git a/docs/examples.md b/docs/examples.md index ff7d1640..12e0f231 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -77,10 +77,10 @@ automations: fullscreen: true actions: - action: custom:advanced-camera-card-action - advanced_camera_card_action: live_substream_on + advanced_camera_card_action: substream_on actions_not: - 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 @@ -117,7 +117,7 @@ elements: advanced_camera_card_action: display_mode_select display_mode: single - 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 advanced_camera_card_action: fullscreen automations: @@ -126,7 +126,7 @@ automations: fullscreen: false actions: - 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 advanced_camera_card_action: display_mode_select display_mode: grid @@ -637,10 +637,10 @@ cameras: automations: - actions: - action: custom:advanced-camera-card-action - advanced_camera_card_action: live_substream_on + advanced_camera_card_action: substream_on actions_not: - action: custom:advanced-camera-card-action - advanced_camera_card_action: live_substream_off + advanced_camera_card_action: substream_off conditions: - condition: interaction interaction: true diff --git a/docs/usage/url-actions.md b/docs/usage/url-actions.md index 101a66cb..b6a70b07 100644 --- a/docs/usage/url-actions.md +++ b/docs/usage/url-actions.md @@ -32,11 +32,11 @@ To send an action to a specific named Advanced Camera Card: > [!NOTE] > For backwards compatibility, the card will also respond to `frigate-card-action` as a query-parameter with the same syntax. -| Parameter | Description | -| --------- | ------------------------------------------------------------------------------------------------------ | -| `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. | -| `VALUE` | An optional value to use with the `camera_select` and `live_substream_select` actions. | +| Parameter | Description | +| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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. | +| `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] > Both `.` and `:` may be used as the delimiter. If you use `:` some @@ -51,38 +51,39 @@ 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. -| Action | Supported in URL | Explanation | -| ------------------------------------------------------------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------- | -| `camera_select` | :white_check_mark: | | -| `camera_ui` | :white_check_mark: | | -| `clip` | :white_check_mark: | | -| `clips` | :white_check_mark: | | -| `default` | :white_check_mark: | | -| `diagnostics` | :white_check_mark: | | -| `download` | :heavy_multiplication_x: | Latest media information is not available on initial render. | -| `expand` | :white_check_mark: | | -| `folder` | :white_check_mark: | | -| `folders` | :white_check_mark: | | -| `fullscreen` | :heavy_multiplication_x: | Javascript does not support activating fullscreen without direct human interaction. Use `expand` as an alternative. | -| `gallery` | :white_check_mark: | | -| `image` | :white_check_mark: | | -| `live_substream_select` | :white_check_mark: | | -| `live` | :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. | -| `menu_toggle` | :white_check_mark: | | -| `microphone_connect`, `microphone_disconnect`, `microphone_mute`, `microphone_unmute` | :heavy_multiplication_x: | | -| `mute`, `unmute` | :heavy_multiplication_x: | | -| `play`, `pause` | :heavy_multiplication_x: | | -| `ptz` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. | -| `recording` | :white_check_mark: | | -| `recordings` | :white_check_mark: | | -| `review` | :white_check_mark: | | -| `reviews` | :white_check_mark: | | -| `screenshot` | :heavy_multiplication_x: | Latest media information is not available on initial render. | -| `ptz_controls` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. | -| `snapshot` | :white_check_mark: | | -| `snapshots` | :white_check_mark: | | +| Action | Supported in URL | Explanation | +| ------------------------------------------------------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | +| `camera_select` | :white_check_mark: | | +| `camera_ui` | :white_check_mark: | | +| `clip` | :white_check_mark: | | +| `clips` | :white_check_mark: | | +| `default` | :white_check_mark: | | +| `diagnostics` | :white_check_mark: | | +| `download` | :heavy_multiplication_x: | Latest media information is not available on initial render. | +| `expand` | :white_check_mark: | | +| `folder` | :white_check_mark: | | +| `folders` | :white_check_mark: | | +| `fullscreen` | :heavy_multiplication_x: | Javascript does not support activating fullscreen without direct human interaction. Use `expand` as an alternative. | +| `gallery` | :white_check_mark: | | +| `image` | :white_check_mark: | | +| `live` | :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. | +| `menu_toggle` | :white_check_mark: | | +| `microphone_connect`, `microphone_disconnect`, `microphone_mute`, `microphone_unmute` | :heavy_multiplication_x: | | +| `mute`, `unmute` | :heavy_multiplication_x: | | +| `play`, `pause` | :heavy_multiplication_x: | | +| `ptz` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. | +| `recording` | :white_check_mark: | | +| `recordings` | :white_check_mark: | | +| `review` | :white_check_mark: | | +| `reviews` | :white_check_mark: | | +| `screenshot` | :heavy_multiplication_x: | Latest media information is not available on initial render. | +| `ptz_controls` | :heavy_multiplication_x: | Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need this. | +| `snapshot` | :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 diff --git a/src/card-controller/actions/actions/substream-off.ts b/src/card-controller/actions/actions/substream-off.ts index 7c536950..bc9e2782 100644 --- a/src/card-controller/actions/actions/substream-off.ts +++ b/src/card-controller/actions/actions/substream-off.ts @@ -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 { SubstreamViewModifier } from '../../view/modifiers/substream'; import { AdvancedCameraCardAction } from './base'; -export class SubstreamOffAction extends AdvancedCameraCardAction { +export class SubstreamOffAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { await super.execute(api); api.getViewManager().setViewByParameters({ - modifiers: [new SubstreamViewModifier()], + modifiers: [new SubstreamViewModifier({ camera: this._action.camera })], }); } } diff --git a/src/card-controller/actions/actions/substream-on.ts b/src/card-controller/actions/actions/substream-on.ts index e0c17306..fe5cbc1b 100644 --- a/src/card-controller/actions/actions/substream-on.ts +++ b/src/card-controller/actions/actions/substream-on.ts @@ -1,11 +1,11 @@ import { CameraManager } from '../../../camera-manager/manager'; -import { SubstreamViewModifier } from '../../view/modifiers/substream'; -import { GeneralActionConfig } from '../../../config/schema/actions/custom/general'; +import { SubstreamOnActionConfig } from '../../../config/schema/actions/custom/substream-on'; import { View } from '../../../view/view'; import { CardActionsAPI } from '../../types'; +import { SubstreamViewModifier } from '../../view/modifiers/substream'; import { AdvancedCameraCardAction } from './base'; -export class SubstreamOnAction extends AdvancedCameraCardAction { +export class SubstreamOnAction extends AdvancedCameraCardAction { public async execute(api: CardActionsAPI): Promise { await super.execute(api); @@ -14,35 +14,38 @@ export class SubstreamOnAction extends AdvancedCameraCardAction { - public async execute(api: CardActionsAPI): Promise { - await super.execute(api); - - api.getViewManager().setViewByParameters({ - modifiers: [new SubstreamViewModifier(this._action.camera)], - }); - } -} diff --git a/src/card-controller/actions/factory.ts b/src/card-controller/actions/factory.ts index 1bcd734b..c6cb824a 100644 --- a/src/card-controller/actions/factory.ts +++ b/src/card-controller/actions/factory.ts @@ -43,7 +43,6 @@ import { SleepAction } from './actions/sleep'; import { StatusBarAction } from './actions/status-bar'; import { SubstreamOffAction } from './actions/substream-off'; import { SubstreamOnAction } from './actions/substream-on'; -import { SubstreamSelectAction } from './actions/substream-select'; import { ToggleAction } from './actions/toggle'; import { UnmuteAction } from './actions/unmute'; import { URLAction } from './actions/url'; @@ -130,11 +129,9 @@ export class ActionFactory { return new CallEndAction(context, action, options?.config); case 'camera_select': return new CameraSelectAction(context, action, options?.config); - case 'live_substream_select': - return new SubstreamSelectAction(context, action, options?.config); - case 'live_substream_off': + case 'substream_off': return new SubstreamOffAction(context, action, options?.config); - case 'live_substream_on': + case 'substream_on': return new SubstreamOnAction(context, action, options?.config); case 'media_player': return new MediaPlayerAction(context, action, options?.config); diff --git a/src/card-controller/call/manager.ts b/src/card-controller/call/manager.ts index b0573001..0891ffdf 100644 --- a/src/card-controller/call/manager.ts +++ b/src/card-controller/call/manager.ts @@ -134,7 +134,7 @@ export class CallManager { ...(needsNavigation && { params: { view: 'live', camera: parentID }, }), - modifiers: [new SubstreamViewModifier(callCameraID, parentID)], + modifiers: [new SubstreamViewModifier({ stream: callCameraID, camera: parentID })], force: true, }); this._api.getConditionStateManager().setState({ call: true }); @@ -255,12 +255,11 @@ export class CallManager { const previousStream = getStreamCameraID(previousView, call.cameraID); viewManager.setViewByParameters({ modifiers: [ - new SubstreamViewModifier( - previousStream && previousStream !== call.cameraID - ? previousStream - : undefined, - call.cameraID, - ), + new SubstreamViewModifier({ + ...(previousStream && + previousStream !== call.cameraID && { stream: previousStream }), + camera: call.cameraID, + }), ], force: true, }); diff --git a/src/card-controller/config/load-control-entities.ts b/src/card-controller/config/load-control-entities.ts index d2650fe6..c4418753 100644 --- a/src/card-controller/config/load-control-entities.ts +++ b/src/card-controller/config/load-control-entities.ts @@ -71,10 +71,7 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => { actions: [ cameraPriority === 'entity' ? // Set the currently selected camera to the state of the entity. - createCameraAction( - 'camera_select', - `{{ hass.states["${cameraControlEntity}"].state }}`, - ) + createCameraAction(`{{ hass.states["${cameraControlEntity}"].state }}`) : // Set the selected option in the entity to the current camera ID. createInternalCallbackAction(async (api: CardActionsAPI) => { const camera = api.getViewManager().getView()?.camera ?? undefined; @@ -92,10 +89,7 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => { ], actions: [ // When the entity state changes, updated the selected option. - createCameraAction( - 'camera_select', - '{{ advanced_camera_card.trigger.state.to }}', - ), + createCameraAction('{{ advanced_camera_card.trigger.state.to }}'), ], tag: automationTag, }, diff --git a/src/card-controller/lock/call-policy.ts b/src/card-controller/lock/call-policy.ts index c6a2871c..dae8d1dd 100644 --- a/src/card-controller/lock/call-policy.ts +++ b/src/card-controller/lock/call-policy.ts @@ -22,9 +22,8 @@ const CALL_DISRUPTIVE_ACTIONS: ReadonlySet = new Set([ 'default', // Substreams. - 'live_substream_select', - 'live_substream_on', - 'live_substream_off', + 'substream_on', + 'substream_off', // Stream-disrupting actions. `play` is intentionally NOT here: it's the // recovery path from a paused state. diff --git a/src/card-controller/query-string-manager.ts b/src/card-controller/query-string-manager.ts index 166d3fbc..55c29792 100644 --- a/src/card-controller/query-string-manager.ts +++ b/src/card-controller/query-string-manager.ts @@ -3,6 +3,8 @@ import { AdvancedCameraCardCustomActionConfig } from '../config/schema/actions/t import { createCameraAction, createGeneralAction, + createSubstreamOffAction, + createSubstreamOnAction, createViewAction, } from '../utils/action.js'; import { CardQueryStringAPI } from './types'; @@ -12,7 +14,12 @@ import { ViewParametersUserSpecified } from './view/types.js'; interface QueryStringViewIntent { view?: ViewParametersUserSpecified & { 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[]; } @@ -44,14 +51,20 @@ export class QueryStringManager { private async _executeViewRelated(intent: QueryStringViewIntent): Promise { if (intent.view) { + const modifiers = + intent.view.stream !== undefined + ? [ + new SubstreamViewModifier( + intent.view.stream === null ? {} : { stream: intent.view.stream }, + ), + ] + : undefined; if (intent.view.default) { await this._api.getViewManager().setViewDefaultWithNewQuery({ params: { camera: intent.view.camera, }, - ...(intent.view.substream && { - modifiers: [new SubstreamViewModifier(intent.view.substream)], - }), + ...(modifiers && { modifiers }), }); } else { await this._api.getViewManager().setViewByParametersWithNewQuery({ @@ -59,9 +72,7 @@ export class QueryStringManager { ...(intent.view.view && { view: intent.view.view }), ...(intent.view.camera && { camera: intent.view.camera }), }, - ...(intent.view.substream && { - modifiers: [new SubstreamViewModifier(intent.view.substream)], - }), + ...(modifiers && { modifiers }), }); } } @@ -84,8 +95,13 @@ export class QueryStringManager { (result.view ??= {}).view = undefined; } else if (action.advanced_camera_card_action === 'camera_select') { (result.view ??= {}).camera = action.camera; - } else if (action.advanced_camera_card_action === 'live_substream_select') { - (result.view ??= {}).substream = action.camera; + } else if ( + 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 { (result.other ??= []).push(action); } @@ -116,11 +132,19 @@ export class QueryStringManager { let action: AdvancedCameraCardCustomActionConfig | null = null; switch (actionName) { case 'camera_select': - case 'live_substream_select': if (value) { - action = createCameraAction(actionName, value, { cardID }); + action = createCameraAction(value, { cardID }); } break; + case 'substream_on': + action = createSubstreamOnAction({ + ...(value && { stream: value }), + cardID, + }); + break; + case 'substream_off': + action = createSubstreamOffAction({ cardID }); + break; case 'camera_ui': case 'default': case 'download': diff --git a/src/card-controller/view/modifiers/substream.ts b/src/card-controller/view/modifiers/substream.ts index 82945f87..d503126d 100644 --- a/src/card-controller/view/modifiers/substream.ts +++ b/src/card-controller/view/modifiers/substream.ts @@ -1,30 +1,38 @@ import { View } from '../../../view/view'; import { ViewModifier } from '../types'; -// The single write path for the camera-keyed `live.overrides` map (the read -// path being `getStreamCameraID` in `view/substream`): sets a camera's -// substream override, or clears it when `substreamID` is absent so the -// camera's own stream is used. `cameraID` defaults to the selected camera. -export class SubstreamViewModifier implements ViewModifier { - private _substreamID?: string; - private _cameraID?: string; +interface SubstreamViewModifierOptions { + // The substream to engage. When absent, the override is cleared so the + // camera's own stream is used. + stream?: string; - constructor(substreamID?: string, cameraID?: string) { - this._substreamID = substreamID; - this._cameraID = cameraID; + // The camera whose override to write. Defaults to the selected camera. + camera?: string; +} + +// 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 { - const cameraID = this._cameraID ?? view.camera; + const cameraID = this._options.camera ?? view.camera; if (!cameraID) { 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); return; } const overrides = view.context?.live?.overrides ?? new Map(); - overrides.set(cameraID, this._substreamID); + overrides.set(cameraID, this._options.stream); view.mergeInContext({ live: { overrides } }); } } diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index 1a5b3378..decbdfe0 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -27,6 +27,8 @@ import { createPTZControlsAction, createPTZMultiAction, createSetReviewAction, + createSubstreamOffAction, + createSubstreamOnAction, createViewAction, isAdvancedCameraCardCustomAction, } from '../utils/action'; @@ -166,7 +168,6 @@ export class MenuButtonController { const menuCameraIDs = cameraManager.getStore().getCameraIDsWithCapability('menu'); if (menuCameraIDs.size > 1) { const submenuItems = Array.from(menuCameraIDs, (cameraID) => { - const action = createCameraAction('camera_select', cameraID); const metadata = cameraManager.getCameraMetadata(cameraID); return { @@ -176,7 +177,7 @@ export class MenuButtonController { state_color: true, title: metadata?.title, 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'), ...config.menu.buttons.substreams, type: 'custom:advanced-camera-card-menu-icon', - tap_action: createGeneralAction( - hasSubstream(view) ? 'live_substream_off' : 'live_substream_on', - ), + tap_action: hasSubstream(view) + ? createSubstreamOffAction() + : createSubstreamOnAction(), }; } else if (streams.length > 2) { const menuItems = Array.from(streams, (streamID) => { - const action = createCameraAction('live_substream_select', streamID); const metadata = cameraManager.getCameraMetadata(streamID) ?? undefined; return { enabled: true, @@ -236,7 +236,7 @@ export class MenuButtonController { state_color: true, title: metadata?.title, selected: substreamAwareCameraID === streamID, - ...(action && { tap_action: action }), + tap_action: createSubstreamOnAction({ stream: streamID }), }; }); @@ -538,10 +538,10 @@ export class MenuButtonController { entity: metadata?.icon.entity, state_color: true, title: metadata?.title, - tap_action: createCallStartAction( - cameraID, - streamID === cameraID ? undefined : streamID, - ), + tap_action: createCallStartAction({ + camera: cameraID, + ...(streamID !== cameraID && { stream: streamID }), + }), }; }); @@ -676,8 +676,6 @@ export class MenuButtonController { .map((playerEntityID) => { const title = getEntityTitle(hass, playerEntityID) || playerEntityID; const state = hass.states[playerEntityID]; - const playAction = createMediaPlayerAction(playerEntityID, 'play'); - const stopAction = createMediaPlayerAction(playerEntityID, 'stop'); const disabled = !state || state.state === 'unavailable'; return { @@ -687,8 +685,10 @@ export class MenuButtonController { state_color: false, title: title, disabled: disabled, - ...(!disabled && playAction && { tap_action: playAction }), - ...(!disabled && stopAction && { hold_action: stopAction }), + ...(!disabled && { + tap_action: createMediaPlayerAction(playerEntityID, 'play'), + hold_action: createMediaPlayerAction(playerEntityID, 'stop'), + }), }; }); diff --git a/src/config/management.ts b/src/config/management.ts index 8f6f4cc3..c5b5a9e4 100644 --- a/src/config/management.ts +++ b/src/config/management.ts @@ -827,6 +827,36 @@ const microphoneConnectedToCallTransform = (data: unknown): boolean => { 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 => { if (typeof data !== 'object' || !data || Array.isArray(data)) { return data; @@ -1051,4 +1081,13 @@ const UPGRADES = [ 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) : {}, + ); + }, ]; diff --git a/src/config/schema/actions/custom/general.ts b/src/config/schema/actions/custom/general.ts index f0fec764..4db2b8e4 100644 --- a/src/config/schema/actions/custom/general.ts +++ b/src/config/schema/actions/custom/general.ts @@ -8,8 +8,6 @@ const GENERAL_ACTIONS = [ 'expand', 'fullscreen', 'info', - 'live_substream_off', - 'live_substream_on', 'menu_toggle', 'microphone_connect', 'microphone_disconnect', diff --git a/src/config/schema/actions/custom/substream-off.ts b/src/config/schema/actions/custom/substream-off.ts new file mode 100644 index 00000000..12b07249 --- /dev/null +++ b/src/config/schema/actions/custom/substream-off.ts @@ -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; diff --git a/src/config/schema/actions/custom/substream-on.ts b/src/config/schema/actions/custom/substream-on.ts new file mode 100644 index 00000000..1edbef58 --- /dev/null +++ b/src/config/schema/actions/custom/substream-on.ts @@ -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; diff --git a/src/config/schema/actions/custom/substream-select.ts b/src/config/schema/actions/custom/substream-select.ts deleted file mode 100644 index 7b83d654..00000000 --- a/src/config/schema/actions/custom/substream-select.ts +++ /dev/null @@ -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 ->; diff --git a/src/config/schema/actions/types.ts b/src/config/schema/actions/types.ts index dd11b913..f32eec3a 100644 --- a/src/config/schema/actions/types.ts +++ b/src/config/schema/actions/types.ts @@ -18,7 +18,8 @@ import { ptzDigitalActionConfigSchema } from './custom/ptz-digital'; import { ptzMultiActionSchema } from './custom/ptz-multi'; import { setReviewActionConfigSchema } from './custom/set-review'; 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 { stockActionSchema } from './stock/types'; @@ -76,7 +77,8 @@ const advancedCameraCardCustomActionSchema = z.union([ setReviewActionConfigSchema, sleepActionConfigSchema, statusBarActionConfigSchema, - substreamSelectActionConfigSchema, + substreamOffActionConfigSchema, + substreamOnActionConfigSchema, viewActionConfigSchema, viewDisplayModeActionConfigSchema, ]); diff --git a/src/utils/action.ts b/src/utils/action.ts index 20e4521f..44257aae 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -27,7 +27,8 @@ import { PTZActionPhase, } from '../config/schema/actions/custom/ptz.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 { PerformActionActionConfig } from '../config/schema/actions/stock/perform-action.js'; import type { Notification } from '../config/schema/actions/types.js'; @@ -72,20 +73,45 @@ export function createViewAction( } export function createCameraAction( - action: 'camera_select' | 'live_substream_select', camera: string, options?: { cardID?: string; }, -): CameraSelectActionConfig | SubstreamSelectActionConfig { +): CameraSelectActionConfig { return { action: 'fire-dom-event', - advanced_camera_card_action: action, + advanced_camera_card_action: 'camera_select', camera: camera, ...(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( mediaPlayer: string, mediaPlayerAction: 'play' | 'stop', @@ -274,18 +300,16 @@ export function createSetReviewAction(reviewed?: boolean): SetReviewActionConfig }; } -export function createCallStartAction( - camera?: string, - stream?: string, - options?: { - cardID?: string; - }, -): CallStartActionConfig { +export function createCallStartAction(options?: { + camera?: string; + stream?: string; + cardID?: string; +}): CallStartActionConfig { return { action: 'fire-dom-event', advanced_camera_card_action: 'call_start', - ...(camera && { camera }), - ...(stream && { stream }), + ...(options?.camera && { camera: options.camera }), + ...(options?.stream && { stream: options.stream }), ...(options?.cardID && { card_id: options.cardID }), }; } diff --git a/tests/card-controller/actions/actions/substream-off.test.ts b/tests/card-controller/actions/actions/substream-off.test.ts index 559e2f5d..9d9412cc 100644 --- a/tests/card-controller/actions/actions/substream-off.test.ts +++ b/tests/card-controller/actions/actions/substream-off.test.ts @@ -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 { SubstreamViewModifier } from '../../../../src/card-controller/view/modifiers/substream'; -import { createCardAPI } from '../../../test-utils'; +import { applyViewModifiers } from '../../../../src/card-controller/view/modifiers'; +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 => { const api = createCardAPI(); - const action = new SubstreamOffAction( - {}, - { - action: 'fire-dom-event', - advanced_camera_card_action: 'live_substream_off', - }, - ); + vi.mocked(api.getViewManager().getView).mockReturnValue(view); - await action.execute(api); + await new SubstreamOffAction({}, createSubstreamOffAction(options)).execute(api); - expect(api.getViewManager().setViewByParameters).toBeCalledWith({ - modifiers: [expect.any(SubstreamViewModifier)], + 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', + ); }); }); diff --git a/tests/card-controller/actions/actions/substream-on.test.ts b/tests/card-controller/actions/actions/substream-on.test.ts index 1bc32c35..9f2c6a2a 100644 --- a/tests/card-controller/actions/actions/substream-on.test.ts +++ b/tests/card-controller/actions/actions/substream-on.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { CameraManagerStore } from '../../../../src/camera-manager/store'; import { SubstreamOnAction } from '../../../../src/card-controller/actions/actions/substream-on'; import { applyViewModifiers } from '../../../../src/card-controller/view/modifiers'; +import { createSubstreamOnAction } from '../../../../src/utils/action'; import { getStreamCameraID } from '../../../../src/view/substream'; import { View } from '../../../../src/view/view'; import { @@ -13,15 +14,6 @@ import { createView, } 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`. const createStoreWithSubstreams = (): CameraManagerStore => createStore([ @@ -37,64 +29,148 @@ const createStoreWithSubstreams = (): CameraManagerStore => ]); // Runs the on-action for `view`, applies the modifier it produces (via the real -// `applyViewModifiers`), and returns the resulting engaged stream. -const getStreamAfterSubstreamOn = async ( +// `applyViewModifiers`), and returns the engaged stream for the override key +// the modifier wrote (`camera` if set, otherwise the selected camera). +const applySubstreamOn = async ( view: View, - store: CameraManagerStore = createStoreWithSubstreams(), + options?: { + store?: CameraManagerStore; + camera?: string; + stream?: string; + }, ): Promise => { const api = createCardAPI(); 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]; applyViewModifiers(view, params?.modifiers); - return getStreamCameraID(view); + return getStreamCameraID(view, options?.camera); }; describe('SubstreamOnAction', () => { - it('should advance to the next dependency', async () => { - expect( - await getStreamAfterSubstreamOn( - createView({ view: 'live', camera: 'camera.office' }), - ), - ).toBe('camera.kitchen'); - }); - - it('should wrap back to the parent camera', async () => { - const view = createView({ - view: 'live', - camera: 'camera.office', - context: { - live: { overrides: new Map([['camera.office', 'camera.kitchen']]) }, - }, + describe('cycling (no `stream` parameter)', () => { + it('should advance to the next dependency', async () => { + expect( + await applySubstreamOn(createView({ view: 'live', camera: 'camera.office' })), + ).toBe('camera.kitchen'); }); - expect(await getStreamAfterSubstreamOn(view)).toBe('camera.office'); - }); + it('should wrap back to the parent camera', async () => { + const view = createView({ + view: 'live', + camera: 'camera.office', + context: { + live: { overrides: new Map([['camera.office', 'camera.kitchen']]) }, + }, + }); - it('should treat a malformed override as the start of the cycle', async () => { - const view = createView({ - view: 'live', - camera: 'camera.office', - context: { - live: { overrides: new Map([['camera.office', 'NOT_A_REAL_CAMERA']]) }, - }, + expect(await applySubstreamOn(view)).toBe('camera.office'); }); - expect(await getStreamAfterSubstreamOn(view)).toBe('camera.office'); + it('should treat a malformed override as the start of the cycle', async () => { + const view = createView({ + view: 'live', + camera: 'camera.office', + context: { + live: { overrides: new Map([['camera.office', 'NOT_A_REAL_CAMERA']]) }, + }, + }); + + expect(await applySubstreamOn(view)).toBe('camera.office'); + }); + + it('should engage no substream when there are no usable dependencies', async () => { + const view = createView({ view: 'live', camera: 'camera.office' }); + + expect(await applySubstreamOn(view, { store: createStore() })).toBe( + 'camera.office', + ); + }); }); - it('should engage no substream when there are no usable dependencies', async () => { - const view = createView({ view: 'live', camera: 'camera.office' }); + describe('with an explicit `stream`', () => { + it('should engage that stream on the selected camera', async () => { + expect( + await applySubstreamOn(createView({ view: 'live', camera: 'camera.office' }), { + stream: 'camera.kitchen', + }), + ).toBe('camera.kitchen'); + }); - expect(await getStreamAfterSubstreamOn(view, createStore())).toBe('camera.office'); + 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', + ); + }); }); - it('should engage no substream when the view has no camera', async () => { + 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 getStreamAfterSubstreamOn(createView({ view: 'live', camera: null })), + await applySubstreamOn(createView({ view: 'live', camera: null })), ).toBeNull(); }); @@ -102,7 +178,7 @@ describe('SubstreamOnAction', () => { const api = createCardAPI(); vi.mocked(api.getViewManager().getView).mockReturnValue(null); - await createAction().execute(api); + await new SubstreamOnAction({}, createSubstreamOnAction()).execute(api); expect(api.getViewManager().setViewByParameters).not.toBeCalled(); }); diff --git a/tests/card-controller/actions/actions/substream-select.test.ts b/tests/card-controller/actions/actions/substream-select.test.ts deleted file mode 100644 index e17a2f37..00000000 --- a/tests/card-controller/actions/actions/substream-select.test.ts +++ /dev/null @@ -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)], - }); -}); diff --git a/tests/card-controller/actions/factory.test.ts b/tests/card-controller/actions/factory.test.ts index 3ec70bb2..b7e687f8 100644 --- a/tests/card-controller/actions/factory.test.ts +++ b/tests/card-controller/actions/factory.test.ts @@ -40,7 +40,6 @@ import { SleepAction } from '../../../src/card-controller/actions/actions/sleep' import { StatusBarAction } from '../../../src/card-controller/actions/actions/status-bar'; import { SubstreamOffAction } from '../../../src/card-controller/actions/actions/substream-off'; 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 { UnmuteAction } from '../../../src/card-controller/actions/actions/unmute'; 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: 'info' as const }, InfoAction], [{ advanced_camera_card_action: 'image' as const }, ViewAction], - [ - { advanced_camera_card_action: 'live_substream_off' as const }, - 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: 'substream_off' as const }, SubstreamOffAction], + [{ advanced_camera_card_action: 'substream_on' as const }, SubstreamOnAction], [{ advanced_camera_card_action: 'live' as const }, ViewAction], [ { diff --git a/tests/card-controller/lock/manager.test.ts b/tests/card-controller/lock/manager.test.ts index 6228826f..5b94364f 100644 --- a/tests/card-controller/lock/manager.test.ts +++ b/tests/card-controller/lock/manager.test.ts @@ -7,6 +7,8 @@ import { createGeneralAction, createLogAction, createMediaPlayerAction, + createSubstreamOffAction, + createSubstreamOnAction, createViewAction, } from '../../../src/utils/action'; import { createCardAPI, createConfig } from '../../test-utils'; @@ -77,10 +79,10 @@ describe('LockManager', () => { for (const action of [ createViewAction('clips'), - createCameraAction('camera_select', 'cam-1'), - createCameraAction('live_substream_select', 'cam-1'), - createGeneralAction('live_substream_on'), - createGeneralAction('live_substream_off'), + createCameraAction('cam-1'), + createSubstreamOnAction({ stream: 'cam-1' }), + createSubstreamOnAction(), + createSubstreamOffAction(), createGeneralAction('default'), createGeneralAction('pause'), createGeneralAction('reload'), diff --git a/tests/card-controller/query-string-manager.test.ts b/tests/card-controller/query-string-manager.test.ts index e0ce0207..da4bc67f 100644 --- a/tests/card-controller/query-string-manager.test.ts +++ b/tests/card-controller/query-string-manager.test.ts @@ -140,10 +140,8 @@ describe('QueryStringManager', () => { expect(api.getViewManager().setViewDefault).not.toBeCalled(); }); - it('should execute live_substream_select action', async () => { - setQueryString( - '?advanced-camera-card-action.id.live_substream_select=camera.office_hd', - ); + it('should execute substream_on with a stream value as a view modifier', async () => { + setQueryString('?advanced-camera-card-action.id.substream_on=camera.office_hd'); const api = createCardAPI(); setCardID(api, 'id'); vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); @@ -154,7 +152,7 @@ describe('QueryStringManager', () => { expect(manager.hasViewRelatedActionsToRun()).toBeFalsy(); expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({ - modifiers: [expect.any(SubstreamViewModifier)], + modifiers: [new SubstreamViewModifier({ stream: 'camera.office_hd' })], params: {}, }); @@ -162,24 +160,77 @@ describe('QueryStringManager', () => { expect(api.getViewManager().setViewDefault).not.toBeCalled(); }); - describe('should ignore action without value', () => { - it.each([['camera_select' as const], ['live_substream_select' as const]])( - '%s', - async (action: string) => { - setQueryString(`?advanced-camera-card-action.id.${action}=`); - const api = createCardAPI(); - setCardID(api, 'id'); - vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); - const manager = new QueryStringManager(api); + it('should dispatch substream_on without a value as a non-view action', async () => { + setQueryString('?advanced-camera-card-action.id.substream_on='); + 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(manager.hasViewRelatedActionsToRun()).toBeFalsy(); + await manager.executeIfNecessary(); - expect(api.getActionsManager().executeActions).not.toBeCalled(); - expect(api.getViewManager().setViewDefault).not.toBeCalled(); - expect(api.getViewManager().setViewByParameters).not.toBeCalled(); - }, + 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(); + 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().setViewDefault).not.toBeCalled(); + expect(api.getViewManager().setViewByParameters).not.toBeCalled(); }); it('should handle unknown action', async () => { @@ -235,7 +286,7 @@ describe('QueryStringManager', () => { it('should handle view and default with camera and substream specified', async () => { setQueryString( '?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.camera_select=camera.kitchen', ); @@ -250,7 +301,7 @@ describe('QueryStringManager', () => { params: { camera: 'camera.kitchen', }, - modifiers: [expect.any(SubstreamViewModifier)], + modifiers: [new SubstreamViewModifier({ stream: 'camera.kitchen_hd' })], }); expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled(); }); @@ -276,9 +327,7 @@ describe('QueryStringManager', () => { }); it('should only execute when needed', async () => { - setQueryString( - '?advanced-camera-card-action.id.live_substream_select=camera.office_hd', - ); + setQueryString('?advanced-camera-card-action.id.substream_on=camera.office_hd'); const api = createCardAPI(); setCardID(api, 'id'); vi.mocked(api.getCardElementManager().hasUpdated).mockReturnValue(true); diff --git a/tests/card-controller/view/modifiers/substream.test.ts b/tests/card-controller/view/modifiers/substream.test.ts index d659d7dd..6394ba7a 100644 --- a/tests/card-controller/view/modifiers/substream.test.ts +++ b/tests/card-controller/view/modifiers/substream.test.ts @@ -6,7 +6,7 @@ describe('SubstreamViewModifier', () => { it('should write the override for the selected 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'); }); @@ -28,7 +28,10 @@ describe('SubstreamViewModifier', () => { 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('camera')).toBe('substream'); @@ -40,15 +43,26 @@ describe('SubstreamViewModifier', () => { 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(); }); + 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', () => { const view = createView({ camera: null }); - new SubstreamViewModifier('substream').modify(view); + new SubstreamViewModifier({ stream: 'substream' }).modify(view); expect(view.context).toBeNull(); }); diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index 531c488b..5e3611f4 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -278,7 +278,7 @@ describe('MenuButtonController', () => { type: 'custom:advanced-camera-card-menu-icon', tap_action: { 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', tap_action: { 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, tap_action: { action: 'fire-dom-event', - advanced_camera_card_action: 'live_substream_select', - camera: 'camera-1', + advanced_camera_card_action: 'substream_on', + stream: 'camera-1', }, }, { @@ -407,8 +407,8 @@ describe('MenuButtonController', () => { selected: false, tap_action: { action: 'fire-dom-event', - advanced_camera_card_action: 'live_substream_select', - camera: 'camera-2', + advanced_camera_card_action: 'substream_on', + stream: 'camera-2', }, }, { @@ -420,8 +420,8 @@ describe('MenuButtonController', () => { selected: false, tap_action: { action: 'fire-dom-event', - advanced_camera_card_action: 'live_substream_select', - camera: 'camera-3', + advanced_camera_card_action: 'substream_on', + stream: 'camera-3', }, }, ], @@ -489,8 +489,8 @@ describe('MenuButtonController', () => { selected: false, tap_action: { action: 'fire-dom-event', - advanced_camera_card_action: 'live_substream_select', - camera: 'camera-1', + advanced_camera_card_action: 'substream_on', + stream: 'camera-1', }, }, { @@ -504,8 +504,8 @@ describe('MenuButtonController', () => { selected: true, tap_action: { action: 'fire-dom-event', - advanced_camera_card_action: 'live_substream_select', - camera: 'camera-2', + advanced_camera_card_action: 'substream_on', + stream: 'camera-2', }, }, { @@ -517,8 +517,8 @@ describe('MenuButtonController', () => { selected: false, tap_action: { action: 'fire-dom-event', - advanced_camera_card_action: 'live_substream_select', - camera: 'camera-3', + advanced_camera_card_action: 'substream_on', + stream: 'camera-3', }, }, ], diff --git a/tests/config/management.test.ts b/tests/config/management.test.ts index c48141f5..fcfb0e9e 100644 --- a/tests/config/management.test.ts +++ b/tests/config/management.test.ts @@ -1897,7 +1897,7 @@ describe('should handle version specific upgrades', () => { actions: [ { 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: [ { 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: [ { 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: [ { 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: [ { 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: [ { 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: [ { 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: [ { 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: [ { 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: [ { 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); }); }); + + 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); + }); + }); }); }); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 66bc1f68..ec382606 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -1054,16 +1054,13 @@ describe('config defaults', () => { }, { 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', - advanced_camera_card_action: 'live_substream_on', - }, - { - action: 'custom:advanced-camera-card-action', - advanced_camera_card_action: 'live_substream_select', - camera: 'camera.front_door_hd', + advanced_camera_card_action: 'substream_on', + camera: 'camera.front_door', + stream: 'camera.front_door_hd', }, { action: 'custom:advanced-camera-card-action', diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index ae10eb10..7390673e 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -20,6 +20,8 @@ import { createPTZMultiAction, createSelectOptionAction, createSetReviewAction, + createSubstreamOffAction, + createSubstreamOnAction, createViewAction, getActionConfigGivenAction, hasAction, @@ -70,14 +72,12 @@ describe('createViewAction', () => { describe('createCameraAction', () => { it('should create camera_select action', () => { - expect(createCameraAction('camera_select', 'camera', { cardID: 'card_id' })).toEqual( - { - action: 'fire-dom-event', - camera: 'camera', - advanced_camera_card_action: 'camera_select', - card_id: 'card_id', - }, - ); + expect(createCameraAction('camera', { cardID: 'card_id' })).toEqual({ + action: 'fire-dom-event', + camera: 'camera', + advanced_camera_card_action: 'camera_select', + card_id: 'card_id', + }); }); }); @@ -370,7 +370,9 @@ describe('createCallStartAction', () => { it('should create call start action with a camera, stream and cardID', () => { expect( - createCallStartAction('camera.front', 'camera.front_doorbell', { + createCallStartAction({ + camera: 'camera.front', + stream: 'camera.front_doorbell', cardID: 'card_id', }), ).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', () => { it('should create call end action', () => { expect(createCallEndAction()).toEqual({