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
@@ -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<GeneralActionConfig> {
export class SubstreamOffAction extends AdvancedCameraCardAction<SubstreamOffActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
api.getViewManager().setViewByParameters({
modifiers: [new SubstreamViewModifier()],
modifiers: [new SubstreamViewModifier({ camera: this._action.camera })],
});
}
}
@@ -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<GeneralActionConfig> {
export class SubstreamOnAction extends AdvancedCameraCardAction<SubstreamOnActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await super.execute(api);
@@ -14,35 +14,38 @@ export class SubstreamOnAction extends AdvancedCameraCardAction<GeneralActionCon
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({
modifiers: [
new SubstreamViewModifier(
this._getCycledSubstreamID(view, api.getCameraManager()),
),
],
modifiers: [new SubstreamViewModifier({ stream, camera: cameraID })],
});
}
// The next substream in the selected camera's cycle: its `substream`
// dependencies in order, wrapping back round. `undefined` means the camera's
// own stream (no substream).
// The next substream in the camera's cycle: its `substream` dependencies in
// order, wrapping back round. `undefined` means the camera's own stream (no
// substream).
private _getCycledSubstreamID(
view: View,
cameraID: string,
cameraManager: CameraManager,
): string | undefined {
if (!view.camera) {
return undefined;
}
const dependencies = [
...cameraManager.getStore().getAllDependentCameras(view.camera, 'substream'),
...cameraManager.getStore().getAllDependentCameras(cameraID, 'substream'),
];
if (dependencies.length <= 1) {
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 nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
// 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 { 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);
+6 -7
View File
@@ -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,
});
@@ -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,
},
+2 -3
View File
@@ -22,9 +22,8 @@ const CALL_DISRUPTIVE_ACTIONS: ReadonlySet<string> = 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.
+35 -11
View File
@@ -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<void> {
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':
+21 -13
View File
@@ -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<string, string>();
overrides.set(cameraID, this._substreamID);
overrides.set(cameraID, this._options.stream);
view.mergeInContext({ live: { overrides } });
}
}