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
@@ -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);
});
});