feat: Trigger automations on call answered, rejected and hung up (#2591)

This commit is contained in:
Dermot Duffy
2026-07-22 11:11:57 -07:00
committed by GitHub
parent 2400e20134
commit 87ecbc7e45
19 changed files with 737 additions and 97 deletions
+192 -9
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import type { PartialDeep } from 'type-fest';
import { assert, beforeEach, describe, expect, it, vi } from 'vitest';
import { assert, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { CameraManagerStore } from '../../../src/camera-manager/store';
@@ -9,10 +9,14 @@ import { CallManager } from '../../../src/card-controller/call/manager';
import { Ringtone } from '../../../src/card-controller/call/ringtone';
import type { CardController } from '../../../src/card-controller/controller';
import { SubstreamViewModifier } from '../../../src/card-controller/view/modifiers/substream';
import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager';
import type { ConditionStateChange } from '../../../src/condition-trigger/conditions/types';
import { CallTrigger } from '../../../src/condition-trigger/triggers/triggers/call';
import type { TriggerOfType } from '../../../src/condition-trigger/triggers/triggers/types';
import type { RingtoneConfig } from '../../../src/config/schema/live';
import type { AdvancedCameraCardConfig } from '../../../src/config/schema/types';
import { View } from '../../../src/view/view';
import { createTriggerEvaluatorContext } from '../../condition-trigger/triggers/triggers/test-utils';
import {
createCameraConfig,
createCameraManager,
@@ -145,7 +149,6 @@ describe('start', () => {
modifiers: [expect.any(SubstreamViewModifier)],
force: true,
});
expect(api.getConditionStateManager().setState).toBeCalledWith({ call: true });
});
it('should navigate to the live view when started from elsewhere', async () => {
@@ -367,8 +370,6 @@ describe('start', () => {
expect(call?.cameraID).toBe('camera.garage');
expect(call?.previousView?.view).toBe('live');
expect(call?.previousView?.camera).toBe('camera.office');
expect(api.getConditionStateManager().setState).toBeCalledWith({ call: false });
expect(api.getConditionStateManager().setState).toBeCalledWith({ call: true });
});
it('should restart on the same camera with a different stream', async () => {
@@ -656,7 +657,6 @@ describe('end', () => {
modifiers: [expect.any(SubstreamViewModifier)],
force: true,
});
expect(api.getConditionStateManager().setState).toBeCalledWith({ call: false });
});
it('should restore the pre-call substream when ending', async () => {
@@ -1085,7 +1085,6 @@ describe('initialize / uninitialize', () => {
manager.uninitialize();
expect(manager.isActive()).toBe(false);
expect(api.getConditionStateManager().setState).toBeCalledWith({ call: false });
});
it('should ignore further condition state changes after uninitialize', async () => {
@@ -1564,7 +1563,7 @@ describe('unanswered timeout', () => {
});
});
// `start()` calls `setState({ call: true })` to broadcast the new call status;
// `start()` calls `setState()` to broadcast the new call phase;
// a listener that responds by navigating away will fire the manager's own
// condition listener and end the call before `start()` returns. Verify the
// post-setState re-read of the session prevents follow-up work (ringtone /
@@ -1591,10 +1590,10 @@ describe('session end during setState', () => {
const listener = getConditionStateListener(api);
vi.mocked(api.getConditionStateManager().setState).mockImplementation((state) => {
// Simulate a downstream listener that responds to `call: true` by
// Simulate a downstream listener that responds to the inbound ring by
// navigating away. The manager's own listener then ends the call,
// nulling the session before `start()` finishes.
if (state.call === true) {
if (state.call === 'ringing') {
listener({
old: { camera: 'camera.office', view: 'live' },
change: { view: 'clips' },
@@ -1683,3 +1682,187 @@ describe('uninitialize during in-flight start', () => {
expect(api.getNotificationManager().setNotification).not.toBeCalled();
});
});
// The phase the manager publishes is what automations actually react to, so
// these drive a real ConditionStateManager and a real CallTrigger and assert
// the transitions an automation would fire on, rather than that `setState` was
// called.
describe('published phase transitions in condition state', () => {
const createAPIWithRealStateManager = (options?: {
config?: PartialDeep<AdvancedCameraCardConfig>;
store?: CameraManagerStore;
}): { api: CardController; stateManager: ConditionStateManager } => {
const stateManager = new ConditionStateManager();
const api = createAPI({
view: createView({ camera: 'camera.office' }),
...options,
});
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
return { api, stateManager };
};
const watch = (
stateManager: ConditionStateManager,
trigger: TriggerOfType<'call'>,
): Mock => {
const callback = vi.fn();
new CallTrigger(trigger, createTriggerEvaluatorContext({ stateManager })).subscribe(
callback,
);
return callback;
};
it('should fire a ringing trigger when an inbound call starts', async () => {
const { api, stateManager } = createAPIWithRealStateManager();
const manager = new CallManager(api);
manager.initialize();
const ringing = watch(stateManager, { trigger: 'call', to: 'ringing' });
const answered = watch(stateManager, { trigger: 'call', to: 'answered' });
expect(await manager.start({ inbound: true })).toBe(true);
expect(ringing).toHaveBeenCalledTimes(1);
expect(answered).not.toHaveBeenCalled();
});
it('should fire an answered trigger when an outbound call starts', async () => {
const { api, stateManager } = createAPIWithRealStateManager();
const manager = new CallManager(api);
manager.initialize();
const ringing = watch(stateManager, { trigger: 'call', to: 'ringing' });
const answered = watch(stateManager, { trigger: 'call', to: 'answered' });
// Outbound calls are answered by construction, so they never ring.
expect(await manager.start()).toBe(true);
expect(answered).toHaveBeenCalledTimes(1);
expect(ringing).not.toHaveBeenCalled();
});
it('should fire an end trigger when an outbound call ends', async () => {
const { api, stateManager } = createAPIWithRealStateManager();
const manager = new CallManager(api);
manager.initialize();
const ended = watch(stateManager, { trigger: 'call', to: 'idle' });
expect(await manager.start()).toBe(true);
expect(manager.end()).toBe(true);
expect(ended).toHaveBeenCalledTimes(1);
});
it('should fire an answer trigger only for an inbound call that was answered', async () => {
const { api, stateManager } = createAPIWithRealStateManager();
const manager = new CallManager(api);
manager.initialize();
const answered = watch(stateManager, {
trigger: 'call',
from: 'ringing',
to: 'answered',
});
expect(await manager.start({ inbound: true })).toBe(true);
expect(answered).not.toHaveBeenCalled();
expect(manager.answer()).toBe(true);
expect(answered).toHaveBeenCalledTimes(1);
});
it('should fire a reject trigger when an unanswered call times out', async () => {
vi.useFakeTimers();
const { api, stateManager } = createAPIWithRealStateManager({
config: { live: { controls: { call: { unanswered_timeout_seconds: 60 } } } },
});
const manager = new CallManager(api);
manager.initialize();
const rejected = watch(stateManager, {
trigger: 'call',
from: 'ringing',
to: 'idle',
});
const hungUp = watch(stateManager, {
trigger: 'call',
from: 'answered',
to: 'idle',
});
expect(await manager.start({ inbound: true })).toBe(true);
vi.advanceTimersByTime(60_000);
expect(rejected).toHaveBeenCalledTimes(1);
expect(hungUp).not.toHaveBeenCalled();
});
it('should fire a hangup trigger, not a reject, when an answered call ends', async () => {
const { api, stateManager } = createAPIWithRealStateManager();
const manager = new CallManager(api);
manager.initialize();
const rejected = watch(stateManager, {
trigger: 'call',
from: 'ringing',
to: 'idle',
});
const hungUp = watch(stateManager, {
trigger: 'call',
from: 'answered',
to: 'idle',
});
expect(await manager.start({ inbound: true })).toBe(true);
expect(manager.answer()).toBe(true);
expect(manager.end()).toBe(true);
expect(hungUp).toHaveBeenCalledTimes(1);
expect(rejected).not.toHaveBeenCalled();
});
it('should fire a reject trigger when a ringing call is superseded', async () => {
const { api, stateManager } = createAPIWithRealStateManager({
store: createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
{
cameraID: 'camera.garage',
capabilities: createCapabilities({ live: true, '2-way-audio': true }),
},
]),
});
const manager = new CallManager(api);
manager.initialize();
const rejected = watch(stateManager, {
trigger: 'call',
from: 'ringing',
to: 'idle',
});
const ringing = watch(stateManager, { trigger: 'call', to: 'ringing' });
expect(await manager.start({ inbound: true })).toBe(true);
expect(await manager.start({ inbound: true, cameraID: 'camera.garage' })).toBe(true);
// The superseded ring is observably ended before the replacement rings, so
// an automation sees idle in between rather than one continuous call.
expect(rejected).toHaveBeenCalledTimes(1);
expect(ringing).toHaveBeenCalledTimes(2);
});
it('should publish idle when uninitialized during a call', async () => {
const { api, stateManager } = createAPIWithRealStateManager();
const manager = new CallManager(api);
manager.initialize();
const ended = watch(stateManager, { trigger: 'call', to: 'idle' });
expect(await manager.start({ inbound: true })).toBe(true);
manager.uninitialize();
expect(ended).toHaveBeenCalledTimes(1);
});
});
@@ -10,27 +10,53 @@ describe('call condition', () => {
expect(() => callConditionSchema.parse({ condition: 'call' })).toThrow();
});
it('should match when call is true', () => {
const evaluator = createConditionEvaluator(
{ condition: 'call' as const, call: true },
createEvaluatorContext(),
);
expect(evaluator.evaluate({}).result).toBeFalsy();
expect(evaluator.evaluate({ call: true }).result).toBeTruthy();
expect(evaluator.evaluate({ call: false }).result).toBeFalsy();
it('should reject a phase that does not exist', () => {
expect(() =>
callConditionSchema.parse({ condition: 'call', call: 'hungup' }),
).toThrow();
});
it('should match when call is false', () => {
it('should match the ringing phase', () => {
const evaluator = createConditionEvaluator(
{ condition: 'call' as const, call: false },
{ condition: 'call' as const, call: 'ringing' as const },
createEvaluatorContext(),
);
expect(evaluator.evaluate({ call: 'ringing' }).result).toBeTruthy();
expect(evaluator.evaluate({ call: 'answered' }).result).toBeFalsy();
expect(evaluator.evaluate({ call: 'idle' }).result).toBeFalsy();
});
it('should match the answered phase', () => {
const evaluator = createConditionEvaluator(
{ condition: 'call' as const, call: 'answered' as const },
createEvaluatorContext(),
);
expect(evaluator.evaluate({ call: 'answered' }).result).toBeTruthy();
expect(evaluator.evaluate({ call: 'ringing' }).result).toBeFalsy();
});
it('should treat an absent call state as idle', () => {
const evaluator = createConditionEvaluator(
{ condition: 'call' as const, call: 'idle' as const },
createEvaluatorContext(),
);
// With no state.call published, the bare condition matches `false`,
// so `call: false` is satisfied.
expect(evaluator.evaluate({}).result).toBeTruthy();
expect(evaluator.evaluate({ call: true }).result).toBeFalsy();
expect(evaluator.evaluate({ call: false }).result).toBeTruthy();
expect(evaluator.evaluate({ call: 'idle' }).result).toBeTruthy();
expect(evaluator.evaluate({ call: 'ringing' }).result).toBeFalsy();
});
it('should match any phase in a list', () => {
const evaluator = createConditionEvaluator(
{ condition: 'call' as const, call: ['ringing', 'answered'] as const },
createEvaluatorContext(),
);
expect(evaluator.evaluate({ call: 'ringing' }).result).toBeTruthy();
expect(evaluator.evaluate({ call: 'answered' }).result).toBeTruthy();
expect(evaluator.evaluate({ call: 'idle' }).result).toBeFalsy();
expect(evaluator.evaluate({}).result).toBeFalsy();
});
});
@@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest';
import { CallConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/call';
import { CameraConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/camera';
import { DisplayModeConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/display-mode';
import { ExpandConditionEvaluator } from '../../../src/condition-trigger/conditions/conditions/expand';
@@ -19,7 +18,6 @@ type ConditionEvaluatorConstructor = new (...args: never[]) => ConditionEvaluato
describe('createConditionEvaluatorForTrigger', () => {
it.each<[Trigger, ConditionEvaluatorConstructor]>([
[{ trigger: 'call', call: true }, CallConditionEvaluator],
[{ trigger: 'camera', cameras: ['front'] }, CameraConditionEvaluator],
[{ trigger: 'display_mode', display_mode: 'single' }, DisplayModeConditionEvaluator],
[{ trigger: 'expand', expand: true }, ExpandConditionEvaluator],
@@ -40,6 +38,7 @@ describe('createConditionEvaluatorForTrigger', () => {
it.each<[string, Trigger]>([
['a valueless trigger fires on any change', { trigger: 'fullscreen' }],
['config has no matching condition', { trigger: 'config', paths: ['menu.style'] }],
['call matches the change itself', { trigger: 'call', from: 'ringing' }],
[
'stock triggers evaluate themselves',
{ trigger: 'state', entity_id: 'binary_sensor.x' },
@@ -42,7 +42,7 @@ describe('createTriggerEvaluator', () => {
[{ trigger: 'state', entity_id: 'binary_sensor.x' }, StateTrigger],
[{ trigger: 'numeric_state', entity_id: 'sensor.x', above: 5 }, NumericStateTrigger],
[{ trigger: 'template', value_template: '{{ true }}' }, TemplateTrigger],
[{ trigger: 'call', call: true }, CallTrigger],
[{ trigger: 'call', to: 'answered' }, CallTrigger],
[{ trigger: 'camera', cameras: ['front'] }, CameraTrigger],
[{ trigger: 'config' }, ConfigTrigger],
[{ trigger: 'display_mode', display_mode: 'single' }, DisplayModeTrigger],
@@ -18,31 +18,152 @@ describe('CallTrigger', () => {
return { stateManager, callback };
};
it('should treat an absent call state as not-in-call', () => {
it('should treat an absent call state as idle', () => {
const { stateManager, callback } = create({ trigger: 'call' });
// Absent (undefined) is equivalent to false, so this is not a change.
stateManager.setState({ call: false });
// Absent (undefined) is equivalent to idle, so this is not a change.
stateManager.setState({ call: 'idle' });
expect(callback).not.toHaveBeenCalled();
stateManager.setState({ call: true });
stateManager.setState({ call: 'ringing' });
expect(callback).toHaveBeenCalledTimes(1);
});
it('should trigger only on changes to the given value', () => {
const { stateManager, callback } = create({ trigger: 'call', call: true });
stateManager.setState({ call: true });
stateManager.setState({ call: false });
it('should trigger on any phase change without from or to', () => {
const { stateManager, callback } = create({ trigger: 'call' });
stateManager.setState({ call: 'ringing' });
stateManager.setState({ call: 'answered' });
stateManager.setState({ call: 'idle' });
expect(callback).toHaveBeenCalledTimes(3);
});
it('should not trigger when the phase is unchanged', () => {
const { stateManager, callback } = create({ trigger: 'call' });
stateManager.setState({ call: 'ringing' });
stateManager.setState({ call: 'ringing', camera: 'camera.office' });
expect(callback).toHaveBeenCalledTimes(1);
});
it('should trigger on the falling edge to a false value', () => {
const { stateManager, callback } = create({ trigger: 'call', call: false });
it('should trigger when an inbound call is answered', () => {
const { stateManager, callback } = create({
trigger: 'call',
from: 'ringing',
to: 'answered',
});
stateManager.setState({ call: true });
stateManager.setState({ call: 'ringing' });
expect(callback).not.toHaveBeenCalled();
stateManager.setState({ call: false });
stateManager.setState({ call: 'answered' });
expect(callback).toHaveBeenCalledTimes(1);
});
it('should not trigger on an outbound call when from is ringing', () => {
const { stateManager, callback } = create({
trigger: 'call',
from: 'ringing',
to: 'answered',
});
// An outbound call is answered by construction, so it moves from idle
// straight to answered without ringing.
stateManager.setState({ call: 'answered' });
expect(callback).not.toHaveBeenCalled();
});
it('should trigger on an outbound call when only to is given', () => {
const { stateManager, callback } = create({ trigger: 'call', to: 'answered' });
stateManager.setState({ call: 'answered' });
expect(callback).toHaveBeenCalledTimes(1);
});
it('should trigger when a ringing call is rejected', () => {
const { stateManager, callback } = create({
trigger: 'call',
from: 'ringing',
to: 'idle',
});
stateManager.setState({ call: 'ringing' });
expect(callback).not.toHaveBeenCalled();
stateManager.setState({ call: 'idle' });
expect(callback).toHaveBeenCalledTimes(1);
});
it('should not trigger a reject when an answered call ends', () => {
const { stateManager, callback } = create({
trigger: 'call',
from: 'ringing',
to: 'idle',
});
stateManager.setState({ call: 'ringing' });
stateManager.setState({ call: 'answered' });
stateManager.setState({ call: 'idle' });
expect(callback).not.toHaveBeenCalled();
});
it('should trigger when an answered call is hung up', () => {
const { stateManager, callback } = create({
trigger: 'call',
from: 'answered',
to: 'idle',
});
stateManager.setState({ call: 'answered' });
expect(callback).not.toHaveBeenCalled();
stateManager.setState({ call: 'idle' });
expect(callback).toHaveBeenCalledTimes(1);
});
it('should trigger on both answer and reject with from alone', () => {
const answered = create({ trigger: 'call', from: 'ringing' });
const rejected = create({ trigger: 'call', from: 'ringing' });
answered.stateManager.setState({ call: 'ringing' });
answered.stateManager.setState({ call: 'answered' });
rejected.stateManager.setState({ call: 'ringing' });
rejected.stateManager.setState({ call: 'idle' });
expect(answered.callback).toHaveBeenCalledTimes(1);
expect(rejected.callback).toHaveBeenCalledTimes(1);
});
it('should trigger on any end with to alone', () => {
const { stateManager, callback } = create({ trigger: 'call', to: 'idle' });
stateManager.setState({ call: 'ringing' });
stateManager.setState({ call: 'idle' });
stateManager.setState({ call: 'answered' });
stateManager.setState({ call: 'idle' });
expect(callback).toHaveBeenCalledTimes(2);
});
it('should match any phase in a from or to list', () => {
const { stateManager, callback } = create({
trigger: 'call',
from: ['ringing', 'answered'],
to: ['idle'],
});
stateManager.setState({ call: 'ringing' });
stateManager.setState({ call: 'idle' });
expect(callback).toHaveBeenCalledTimes(1);
stateManager.setState({ call: 'answered' });
stateManager.setState({ call: 'idle' });
expect(callback).toHaveBeenCalledTimes(2);
});
});
+107 -9
View File
@@ -3982,7 +3982,7 @@ describe('should handle version specific upgrades', () => {
});
describe('microphone.connected -> call condition', () => {
it('should rewrite connected:true -> call:true in an automation', () => {
it('should rewrite connected:true into an active-phase trigger in an automation', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
@@ -4000,12 +4000,14 @@ describe('should handle version specific upgrades', () => {
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.automations[0]).toEqual(
expect.objectContaining({ triggers: [{ trigger: 'call', call: true }] }),
expect.objectContaining({
triggers: [{ trigger: 'call', to: ['ringing', 'answered'] }],
}),
);
postUpgradeChecks(config);
});
it('should rewrite connected:false -> call:false', () => {
it('should rewrite connected:false into an idle-phase trigger', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
@@ -4023,7 +4025,7 @@ describe('should handle version specific upgrades', () => {
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.automations[0]).toEqual(
expect.objectContaining({ triggers: [{ trigger: 'call', call: false }] }),
expect.objectContaining({ triggers: [{ trigger: 'call', to: 'idle' }] }),
);
postUpgradeChecks(config);
});
@@ -4074,7 +4076,7 @@ describe('should handle version specific upgrades', () => {
{
condition: 'and',
conditions: [
{ condition: 'call', call: true },
{ condition: 'call', call: ['ringing', 'answered'] },
{ condition: 'microphone', muted: false },
],
},
@@ -4123,7 +4125,7 @@ describe('should handle version specific upgrades', () => {
conditions: [
{
condition: 'not',
conditions: [{ condition: 'call', call: true }],
conditions: [{ condition: 'call', call: ['ringing', 'answered'] }],
},
],
},
@@ -4153,10 +4155,64 @@ describe('should handle version specific upgrades', () => {
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.elements[0].conditions).toEqual([
{ condition: 'call', call: true },
{ condition: 'call', call: ['ringing', 'answered'] },
]);
expect(config.overrides[0].conditions).toEqual([
{ condition: 'call', call: false },
{ condition: 'call', call: 'idle' },
]);
postUpgradeChecks(config);
});
it('should keep a disabled condition disabled through to the trigger', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
automations: [
{
conditions: [{ condition: 'microphone', connected: true, enabled: false }],
actions: [
{ action: 'fire-dom-event', advanced_camera_card_action: 'live' },
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.automations[0]).toEqual(
expect.objectContaining({
triggers: [{ trigger: 'call', to: ['ringing', 'answered'], enabled: false }],
}),
);
postUpgradeChecks(config);
});
it('should keep a disabled condition disabled when split into an AND', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
overrides: [
{
conditions: [
{
condition: 'microphone',
connected: true,
muted: false,
enabled: false,
},
],
merge: {},
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.overrides[0].conditions).toEqual([
{
condition: 'and',
conditions: [
{ condition: 'call', call: ['ringing', 'answered'] },
{ condition: 'microphone', muted: false },
],
enabled: false,
},
]);
postUpgradeChecks(config);
});
@@ -4182,13 +4238,55 @@ describe('should handle version specific upgrades', () => {
// Running upgradeConfig again should not change anything.
expect(upgradeConfig(config)).toBeFalsy();
expect(config.automations[0]).toEqual(
expect.objectContaining({ triggers: [{ trigger: 'call', call: true }] }),
expect.objectContaining({
triggers: [{ trigger: 'call', to: ['ringing', 'answered'] }],
}),
);
postUpgradeChecks(config);
});
});
describe('automation conditions -> triggers', () => {
it('should promote a state condition to a trigger and keep its other fields', () => {
const config = {
type: 'custom:advanced-camera-card',
cameras: [{ camera_entity: 'camera.office' }],
automations: [
{
conditions: [
{
condition: 'state',
entity_id: 'binary_sensor.motion',
state: 'on',
attribute: 'friendly_name',
for: '00:01:00',
enabled: false,
},
],
actions: [
{ action: 'fire-dom-event', advanced_camera_card_action: 'live' },
],
},
],
};
expect(upgradeConfig(config)).toBeTruthy();
expect(config.automations[0]).toEqual(
expect.objectContaining({
triggers: [
{
trigger: 'state',
entity_id: 'binary_sensor.motion',
to: 'on',
attribute: 'friendly_name',
for: '00:01:00',
enabled: false,
},
],
}),
);
postUpgradeChecks(config);
});
it('should flatten a composite condition into trigger leaves and keep the composite', () => {
const config = {
type: 'custom:advanced-camera-card',
+1 -1
View File
@@ -891,7 +891,7 @@ describe('config defaults', () => {
it('should include all conditions', () => {
const conditions = [
{ condition: 'and', conditions: [{ condition: 'initialized' }] },
{ condition: 'call', call: true },
{ condition: 'call', call: ['ringing', 'answered'] },
{ condition: 'camera', cameras: ['camera.office'] },
{ condition: 'display_mode', display_mode: 'single' },
{ condition: 'expand', expand: true },