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,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<void> => {
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',
);
});
});
@@ -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<string | null> => {
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();
});
@@ -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 { 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],
[
{