Home Assistant automation cookbook and dial plan

August 9, 2026 ยท View on GitHub

Warning

Initial experimental preview in 2026.8.0. The normal phonebook route remains the default and does not require automations. Event fields, decision timing and service semantics may change in a future release while this API is validated against more real PBX deployments. Keep critical access-control or emergency routing on an explicitly tested stable path.

VoIP Stack has one canonical dial plan: the shared phonebook. Home Assistant automations may override a call only at the explicit decision points described below. Disabling automation routing restores the ordinary phonebook path without changing contacts, extensions or the configured inbound destination.

The Lovelace card never chooses or filters a route. It mirrors the authoritative backend state and sends user actions to the same router.

Design model

VoIP Stack deliberately does not introduce another dial-plan language inside Home Assistant. Mature PBXs separate call conditions from a small set of call applications:

SystemCondition layerCall primitives
AsteriskContexts, extensions, expressions and GotoIfDial, FollowMe, Queue, Bridge, transfer and hangup applications
FreeSWITCHOrdered XML conditions, channel fields, time rules and anti-actionsbridge, transfer, answer, hangup and other applications
KamailioRequest, branch, reply and failure routesRelay, destination selection, health probing and failover
VoIP StackNative HA state, Event Entity, presence, schedule, calendar and device conditionsInitial selection, forward, transfer, call control and phone policy actions

This gives Home Assistant users the same useful separation without copying a general PBX scripting language. Presence, alarm, calendar, occupancy and light logic stay in HA. SIP dialog, fork, media, transfer and termination logic stay inside VoIP Stack.

Evidence levels and qualified building blocks

The checked-in HA qualification package uses one parameterized route automation and one ringing-forward automation. SIPp and baresip observe the real signaling while the runner verifies call state and cleanup before and after every case.

The project keeps four evidence levels separate:

LevelWhat it proves
CataloguedThe use case is recorded in qualification/dialplan_matrix.py, with its trigger, condition, operation and expected fallback. This is a coverage target, not an executed test.
StaticThe documented YAML parses and its voip_stack.* actions use fields exposed by the current service schemas.
HA runtimeThe action handler and state projections run inside a Home Assistant test installation. Mocked component tests do not count as this level.
Live peerThe checked-in automation runs in the isolated HA laboratory while SIPp or baresip independently observes signaling, termination and post-call cleanup.

The qualification field in the catalogue names the evidence class required for a use case. It does not by itself mean that the class has an executor or that the scenario has passed. Release evidence must identify the concrete scenario result for the exact candidate.

The current real-HA matrix covers:

Building blockReal outcome checked
Decline, busy and cancel route decisionsSIP 603, 486 and 487
No overrideConfigured fallback answers normally
Initial forward and bridgeSelected registered SIP destination answers
Context condition true and falseCorrect branch and destination are recorded
Caller filter mismatchAutomation does nothing and fallback answers
Ringing phone forwardCasa rings first, then the registered destination answers
Failed ringing forward with resumeCasa remains available and caller CANCEL completes correctly
Automatically answering and manually answering registered SIP peersCaller-side and callee-side BYE both clean up
Initial delayed offerOfferless INVITE completes and terminates cleanly

Every case requires zero remaining sessions, legs, pending routes, pending INVITEs, relays, media owners, forward tasks and allocated RTP ports. Inspect the structured catalog with:

scripts/dialplan_matrix.py --validate
scripts/dialplan_matrix.py --json

The live true/false branch uses a controlled qualification boolean. Native HA conditions such as presence, time, calendar, alarm and connectivity feed the same branch from the integration's perspective. Their recipes are listed separately because they solve different user problems, not because each one is claimed as a separate live PBX test.

The registered-peer answer cases above configure baresip answer policy. They do not qualify the voip_stack.set_auto_answer action of a Home Assistant or ESPHome phone. Likewise, the current ringing-forward case acts as soon as the source rings; it qualifies forwarding a live ringing call, not Home Assistant's native for: timer.

The recipes use native Home Assistant Event Entities, state Sensors, conditions and voip_stack.* actions. Replace every example entity, Device ID, person and destination with selections from your own installation. Prefer the automation editor's entity and Device selectors over guessed IDs.

The examples target VoIP Stack 2026.8.0 or later. Repository checks validate their YAML structure, Home Assistant event types and the runtime schemas of every voip_stack.* action they invoke.

Choose the correct call hook

Routing and observation are separate. Use the hook that owns the phase you want to control:

In plain language:

  • route_requested means: "A new call has arrived, but the PBX has not chosen which phone should ring yet."
  • voip_stack.select_inbound_destination answers that request: "Send this new call to this phone, group, extension or Assist."
  • ringing means that the call has already been delivered to a specific phone.
  • voip_stack.forward moves that already-delivered call somewhere else.

The normal sequence is:

Incoming call
  -> route_requested
  -> select_inbound_destination
  -> destination starts ringing
  -> optional forward
GoalTriggerAction
Replace the first destination before the call is deliveredAggregate route_requested Event Entity occurrencevoip_stack.select_inbound_destination
React when one specific phone actually starts ringingThat phone's ringing Event Entity occurrenceNotification or another normal HA action
Wait before replacing an already delivered destinationThat phone's call-state Sensor remains ringing with for:voip_stack.forward
React to a key during an established callPhone or aggregate dtmf Event Entity occurrenceGate, light or another normal HA action
Record completion or failurePhone or aggregate ended, missed or failed occurrenceNotification, log or statistics

route_requested is deliberately the only public event that opens the short initial-routing decision window. A generic incoming-call notification would only describe what happened; it would not grant authority to replace the pending route. Raw SIP event-bus messages are internal plumbing and are not a second automation API.

voip_stack.select_inbound_destination works only while that initial decision is pending. voip_stack.forward works later, after a phone or group has already received the call. It releases or replaces the current destination and follows the configured on_failure policy.

Configure incoming trunk routing

Reconfigure VoIP Stack and choose one Incoming routing mode:

ModeWhat the caller experiencesNormal route
Route immediatelyNo DTMF collection or pre-answer delay.The call follows the fallback destination unless an automation selects another destination.
Collect extension with DTMFHA answers the trunk leg and collects negotiated telephone-event or SIP INFO digits for the configured timeout.A valid extension wins. With no digits, automation gets a decision point before the fallback.

Fallback destination accepts the same values as the rest of the dial plan: a phonebook name, HA, an extension, a group, a registered SIP phone, an Assist extension, a SIP URI or a routable number.

The optional Allow experimental automation routing overrides switch is independent from the incoming mode and is off by default. When enabled:

  • Direct mode exposes a 1.5 second route_requested decision point before the configured default route.
  • DTMF mode exposes the same decision point only when the caller enters no digits.
  • Explicit DTMF digits never pass through automation routing. They always use the phonebook, and an unknown explicit extension ends as route_not_found instead of silently ringing another destination.
  • If no matching automation acts during the decision window, the configured phonebook route continues unchanged.

Existing configurations migrate transparently. A previous setup with DTMF enabled and a non-zero timeout becomes DTMF mode. Other setups become Direct mode. Automation overrides remain disabled until explicitly enabled, while the existing credentials, target and timeout are preserved.

Override the initial destination

Route a door phone to P4 when home, otherwise use the default phone

Suppose Front Door normally calls Casa. When Daniele is home the call must ring Waveshare P4 Touch; when he is away it must ring the original Casa phone. The cleanest implementation selects the destination before either phone starts ringing:

alias: VoIP - Front Door to P4 when home, otherwise Casa
mode: parallel
max: 10
triggers:
  - trigger: event.received
    target:
      entity_id: event.voip_stack_call
    options:
      event_type:
        - route_requested
conditions:
  - condition: state
    entity_id: event.voip_stack_call
    attribute: caller
    state: Front Door
actions:
  - if:
      - condition: state
        entity_id: person.daniele
        state: home
    then:
      - action: voip_stack.select_inbound_destination
        data:
          destination: Waveshare P4 Touch
    else:
      - action: voip_stack.select_inbound_destination
        data:
          destination: Casa

Replace the caller, destinations and person entity with values from the actual installation. If the automation does not match, the configured fallback route continues normally. Use this initial decision when Casa must not ring first.

If Casa must ring before the call is moved, trigger on Casa's durable call-state sensor and use voip_stack.forward instead. With on_failure: resume, Casa resumes ringing if P4 is unavailable:

alias: VoIP - Move Casa call to P4 when home
mode: parallel
max: 10
triggers:
  - trigger: state
    entity_id: sensor.casa_call_state
    to: ringing
conditions:
  - condition: state
    entity_id: person.daniele
    state: home
  - condition: state
    entity_id: sensor.casa_call_state
    attribute: peer_name
    state: Front Door
actions:
  - action: voip_stack.forward
    data:
      device_id: <casa_phone_device_id>
      destination: Waveshare P4 Touch
      on_failure: resume

This complete automation routes a trunk call to Waveshare S3 Audio before the fallback destination. It uses Home Assistant's native Event Entity trigger, so it needs no Jinja, Call-ID or helper timer:

alias: VoIP - Route incoming call to WS3
mode: parallel
max: 10
triggers:
  - trigger: event.received
    target:
      entity_id: event.voip_stack_call
    options:
      event_type:
        - route_requested
conditions:
  - condition: state
    entity_id: event.voip_stack_call
    attribute: ingress
    state: trunk
actions:
  - action: voip_stack.select_inbound_destination
    data:
      destination: Waveshare S3 Audio

Use ordinary Home Assistant conditions between the trigger and action for presence, time, alarm mode or any other entity. For example, a state condition can route to an indoor ESP only while someone is home. If the condition is false, no action runs and the default target takes over when the short decision window expires.

Route a known caller according to presence

This example uses only native Home Assistant conditions. Calls from Wildix extension 426 ring the kitchen ESP while Daniele is home; every other state, including not_home or another HA zone, routes the call to extension 667 (Test). Other callers do not match the automation and continue to the trunk's configured fallback destination.

alias: VoIP - Route 426 according to Daniele presence
description: Route one known trunk caller to WS3 at home, otherwise Test.
mode: parallel
max: 10
triggers:
  - trigger: event.received
    target:
      entity_id: event.voip_stack_call
    options:
      event_type:
        - route_requested
conditions:
  - condition: state
    entity_id: event.voip_stack_call
    attribute: ingress
    state: trunk
  - condition: state
    entity_id: event.voip_stack_call
    attribute: caller
    state: "426"
actions:
  - if:
      - condition: state
        entity_id: person.daniele
        state: home
    then:
      - action: voip_stack.select_inbound_destination
        data:
          destination: Waveshare S3 Audio
    else:
      - action: voip_stack.select_inbound_destination
        data:
          destination: "667"

The caller value is the resolved name or number shown by event.voip_stack_call; replace 426 with the exact value exposed by your caller. The destinations may likewise be phonebook names, extensions, groups, registered SIP phones or Assist.

Route only provider/PBX trunk calls to a ring group

route_requested may also describe an HA-owned extension call. Filter on the stable ingress attribute when an automation must affect only calls entering from the configured provider/PBX trunk:

alias: VoIP - Inbound trunk to RG Casa
mode: parallel
max: 10
triggers:
  - trigger: event.received
    target:
      entity_id: event.voip_stack_call
    options:
      event_type:
        - route_requested
conditions:
  - condition: state
    entity_id: event.voip_stack_call
    attribute: ingress
    state: trunk
actions:
  - action: voip_stack.select_inbound_destination
    data:
      destination: RG Casa

Use state: extension for calls originating from a local ESP, browser phone or registered SIP endpoint. Do not filter on scope: scope identifies the internal state owner, while ingress/origin describe where the call entered the PBX. A ring group uses normal PBX semantics: eligible members ring, the first answer wins, losing legs are cancelled, and the caller is excluded when it is itself a member of the destination group.

This action is only for the initial route_requested decision. When exactly one route is waiting, no Call-ID template is required. With concurrent pending routes, pass the call_id from the event explicitly. The configured fallback remains authoritative when no automation acts. Use voip_stack.forward only after a call has already been delivered to a ringing or connected endpoint.

Forward an unanswered HA call to Assist

Initial routing and no-answer forwarding are separate operations. Once the HA softphone is ringing, its durable state sensor supports Home Assistant's native for: timing:

alias: VoIP - HA unanswered to Assist
mode: parallel
max: 10
triggers:
  - trigger: state
    entity_id: sensor.casa_call_state  # Select Call state on Device Casa
    to: ringing
    for: "00:00:30"
conditions:
  - condition: state
    entity_id: sensor.casa_call_state
    attribute: ingress
    state: trunk
actions:
  - action: voip_stack.forward
    data:
      destination: "1666"
      on_failure: resume

The ringing state already means that this phone is the incoming call target; an additional direction: incoming condition would be redundant. Keep the ingress: trunk condition when only provider/PBX calls should fall through to Assist. Remove the entire conditions: block when unanswered local-extension calls should follow the same rule.

Replace 1666 with any destination understood by the phonebook. When exactly one HA-owned call is forwardable, the backend resolves its Call-ID and current revision itself. The source call remains open while VoIP Stack releases the HA softphone, cancels any replaced ringing leg with SIP CANCEL, and attaches the new destination.

If multiple calls are simultaneously forwardable, an advanced automation must identify the intended call_id. Ambiguous requests fail explicitly instead of guessing.

With multiple logical phones, select the call-state entity attached to the phone that owns the ringing leg. Home Assistant generates and may localize the entity ID, so always select it from the phone Device in the automation editor instead of guessing its ID.

Logical ringing is independent from browser connectivity. A browser softphone that belongs to a ring group is allowed to enter ringing while its connectivity entity says Disconnected; no physical card rings, but state timers and missed-call automations still run. Opening the matching card during that window makes the call answerable. DND and administratively disabled phones are not ring candidates.

For example, the Casa phone can fall through to the Cucina tablet without matching caller names or inspecting the global event stream:

alias: VoIP - Casa unanswered to Cucina
mode: parallel
max: 10
triggers:
  - trigger: state
    entity_id: sensor.casa_call_state  # Select Call state on Device Casa
    to: ringing
    for: "00:00:30"
conditions:
  - condition: state
    entity_id: sensor.casa_call_state
    attribute: ingress
    state: trunk
actions:
  - action: voip_stack.forward
    data:
      destination: Cucina
      on_failure: resume

Common automation recipes

Keep the initial destination decision and later call handling separate. These are the most common patterns. Complete copyable examples follow the table:

GoalTrigger/conditionAction
Ring the whole house for an external callAggregate route_requested with ingress: trunkselect_inbound_destination to a ring group
Route differently when nobody is homeSame initial event plus a normal person/presence state conditionSelect an ESP, HA phone, Assist or another group; otherwise let the configured fallback run
Use an office-hours destinationSame initial event plus a time conditionSelect Reception during opening hours; allow the fallback or select Assist outside them
Send an unanswered room phone elsewhereThat phone's call-state sensor remains ringing for a durationforward to another room, group or Assist
Notify on a no-answer timeoutThat phone's Event Entity receives missedSend a normal HA notification; no routing action is required
React to keypad input during a connected callThe phone or aggregate Event Entity receives dtmfRun a gate, light or other HA action

An explicit DTMF extension entered during initial trunk collection remains authoritative and bypasses the automation override. This prevents a broad automation from replacing a destination deliberately dialled by the caller. Likewise, a false condition should normally perform no action: after the short decision window, VoIP Stack follows the configured fallback transparently.

Route to reception during office hours

This automation affects only calls entering from the provider/PBX trunk. During office hours it selects Reception; outside those hours it performs no action, so the trunk's configured fallback continues normally:

alias: VoIP - Trunk calls to Reception during office hours
mode: parallel
max: 10
triggers:
  - trigger: event.received
    target:
      entity_id: event.voip_stack_call
    options:
      event_type:
        - route_requested
conditions:
  - condition: state
    entity_id: event.voip_stack_call
    attribute: ingress
    state: trunk
  - condition: time
    after: "08:30:00"
    before: "18:00:00"
    weekday:
      - mon
      - tue
      - wed
      - thu
      - fri
actions:
  - action: voip_stack.select_inbound_destination
    data:
      destination: Reception

To send out-of-hours calls to Assist instead, configure Assist as the normal trunk fallback. This keeps one clear routing authority and avoids duplicating the same schedule in two automation branches.

Prefer an available phone

This uses the same integration-side true/false route primitive exercised by the live matrix. Home Assistant supplies the connectivity condition. The specific connectivity recipe remains a static cookbook example until it has a named live scenario:

alias: VoIP - Prefer P4 when online
mode: parallel
max: 10
triggers:
  - trigger: event.received
    target:
      entity_id: event.voip_stack_call
    options:
      event_type: [route_requested]
actions:
  - if:
      - condition: state
        entity_id: binary_sensor.p4_connectivity
        state: "on"
    then:
      - action: voip_stack.select_inbound_destination
        data:
          destination: P4
    else:
      - action: voip_stack.select_inbound_destination
        data:
          destination: Casa

Route holidays to Assist

Calendar, alarm and presence checks do not require PBX-specific syntax. This example routes to Assist only while the holiday calendar is active. Otherwise it performs no action and the configured fallback remains authoritative:

alias: VoIP - Holiday calls to Assist
mode: parallel
max: 10
triggers:
  - trigger: event.received
    target:
      entity_id: event.voip_stack_call
    options:
      event_type: [route_requested]
conditions:
  - condition: state
    entity_id: calendar.company_holidays
    state: "on"
actions:
  - action: voip_stack.select_inbound_destination
    data:
      destination: Assist

Reject calls while the alarm is triggered

Use the Call-ID from the Event Entity when the action rejects a specific pending call. The backend rejects a stale or already settled decision. The real-HA matrix qualifies the decline decision and its 603 response:

alias: VoIP - Reject calls while alarm is triggered
mode: parallel
max: 10
triggers:
  - trigger: event.received
    target:
      entity_id: event.voip_stack_call
    options:
      event_type: [route_requested]
conditions:
  - condition: state
    entity_id: alarm_control_panel.home
    state: triggered
actions:
  - action: voip_stack.route
    data:
      call_id: "{{ trigger.to_state.attributes.call_id }}"
      action: decline

Notify a no-answer timeout

Use the Event Entity belonging to the phone you care about. This example sends one notification when Casa reaches its configured no-answer timeout:

alias: VoIP - Notify Casa no-answer timeout
mode: parallel
max: 10
triggers:
  - trigger: event.received
    target:
      entity_id: event.casa_call
    options:
      event_type:
        - missed
actions:
  - action: notify.mobile_app_your_phone
    data:
      title: "Missed VoIP call"
      message: >-
        Missed call from
        {{ state_attr('event.casa_call', 'caller') or 'Unknown caller' }}

Replace event.casa_call with the call Event Entity shown on the intended phone Device. Using the per-phone entity prevents a missed call on another room phone from triggering this notification. The public missed occurrence currently means that the no-answer timeout expired. If the caller hangs up before that timeout, the Event Entity emits ended; the Logbook still presents that unanswered incoming call as missed.

Forward an unanswered call to a mobile number

First create a phonebook contact containing only the public number. Run this once from Developer tools > Actions, or create the same contact through your own provisioning automation:

action: voip_stack.add_contact
data:
  name: Daniele mobile
  number: "+1234567890"

Then forward the still-ringing Casa call after ten seconds:

alias: VoIP - Casa unanswered to Daniele mobile
mode: parallel
max: 10
triggers:
  - trigger: state
    entity_id: sensor.casa_call_state
    to: ringing
    for: "00:00:10"
conditions:
  - condition: state
    entity_id: sensor.casa_call_state
    attribute: ingress
    state: trunk
actions:
  - action: voip_stack.forward
    data:
      destination: Daniele mobile
      on_failure: resume

This requires a configured and registered SIP trunk able to dial the public number. It creates an ordinary external telephone call, not a Companion-app VoIP channel. on_failure: resume leaves the original call available if the trunk call cannot be started. Remove the ingress: trunk condition if local extension calls should use the same fallback.

Transfer a connected call

Forwarding changes a destination while HA still owns routing. Transfer uses SIP REFER on an established dialog. A blind transfer needs the active call ID:

alias: VoIP - Transfer Casa to Reception
triggers: []
actions:
  - action: voip_stack.transfer
    data:
      call_id: "{{ state_attr('sensor.casa_call_state', 'call_id') }}"
      destination: Reception

For an attended transfer, pass the consultation call as replaces_call_id:

action: voip_stack.transfer
data:
  call_id: "{{ states('input_text.original_call_id') }}"
  destination: Reception
  replaces_call_id: "{{ states('input_text.consultation_call_id') }}"

The transfer implementation has protocol and lifecycle tests. This particular Home Assistant automation recipe is catalogued and schema-checked, but the current live automation matrix does not execute it. The example helpers merely show where an automation can retain its two call IDs.

Set DND from occupancy

alias: VoIP - Casa DND follows occupancy
mode: restart
triggers:
  - trigger: state
    entity_id: zone.home
actions:
  - if:
      - condition: numeric_state
        entity_id: zone.home
        above: 0
    then:
      - action: voip_stack.set_dnd
        data:
          device_id: <casa_phone_device_id>
          dnd: false
    else:
      - action: voip_stack.set_dnd
        data:
          device_id: <casa_phone_device_id>
          dnd: true

Pause media during a call

This recipe changes only ordinary HA media state. The PBX remains the owner of the call lifecycle:

alias: VoIP - Pause living-room media during calls
mode: restart
triggers:
  - trigger: state
    entity_id: sensor.casa_call_state
    to: ringing
  - trigger: state
    entity_id: sensor.casa_call_state
    to: in_call
actions:
  - action: media_player.media_pause
    target:
      entity_id: media_player.living_room

Start a scheduled P4 video call

alias: VoIP - Scheduled P4 video check-in
triggers:
  - trigger: time
    at: "18:00:00"
actions:
  - action: voip_stack.call
    data:
      device_id: <p4_phone_device_id>
      destination: Casa
      send_video: true

The time trigger itself is native Home Assistant behavior. This recipe is catalogued and schema-checked; a release may claim the complete scenario only when its candidate evidence includes the selected phone, video negotiation, termination and the required P4 hardware run.

Actionable doorbell notification

Use the call Event Entity attached to the receiving Home Assistant phone. The ringing occurrence contains caller information, while the selected phone Device lets voip_stack.decline resolve the current call without copying a SIP Call-ID into the automation.

The Answer button must open the dashboard containing that phone's card. Only a browser or Companion view can request microphone/camera permission and attach media. The card consumes ?voip_answer=1 and answers the ringing call.

alias: VoIP - Actionable doorbell notification
mode: restart
triggers:
  - trigger: event.received
    target:
      entity_id: event.casa_call
    options:
      event_type:
        - ringing
conditions:
  - condition: state
    entity_id: event.casa_call
    attribute: caller
    state: Front Door
actions:
  - action: notify.mobile_app_your_phone
    data:
      title: "๐Ÿ”” Front door"
      message: "Front Door is calling Casa"
      data:
        tag: voip_front_door
        channel: doorbell
        importance: high
        ttl: 0
        priority: high
        actions:
          - action: URI
            title: Answer
            uri: /lovelace/phones?voip_answer=1
          - action: VOIP_DECLINE_CASA
            title: Decline
  - wait_for_trigger:
      - trigger: event
        event_type: mobile_app_notification_action
        event_data:
          action: VOIP_DECLINE_CASA
    timeout: "00:00:30"
  - if:
      - condition: template
        value_template: "{{ wait.trigger is not none }}"
    then:
      - action: voip_stack.decline
        data:
          device_id: <casa_phone_device_id>
  - action: notify.mobile_app_your_phone
    data:
      message: clear_notification
      data:
        tag: voip_front_door

Replace /lovelace/phones with the real view containing the card bound to the same Casa phone Device. Receiving the notification does not itself answer or claim media. If the call ends before the action is pressed, the backend rejects the stale decline instead of affecting a later call.

For several phones or simultaneous door stations, use one distinct notification tag/action name per receiving phone. Expert flows may also copy call_id from the event and pass it through action data, but it is unnecessary when exactly one call is ringing on the selected Device.

The same automation is available as a standalone file: examples/doorbell-automation.yaml.

Answer a VoIP call from a Companion notification

Native automation entities

Event entity

Every integration-owned phone Device exposes its own call Event Entity, for example event.casa_call or event.test_call (the visible/entity names are localized). It publishes only occurrences involving that phone. Use it for a doorbell notification, a missed-call log, or behavior specific to one room.

event.voip_stack_call remains the aggregate PBX-wide surface and publishes stateless occurrences for every HA-owned call:

  • route_requested, outgoing_call, calling
  • ringing, remote_ringing, forwarding
  • answered, connected
  • calling_timeout_requested, ringing_timeout_requested
  • dtmf
  • ended, missed, failed, state_changed

Select these through the event.received trigger in the automation editor. Each occurrence includes call metadata such as caller, callee, direction, route kind, owner and controllability. The aggregate entity is useful for initial route_requested decisions and advanced inspection; prefer the phone's own Event Entity for room-specific logic.

Durable state sensor

Each logical browser/SIP-account phone exposes an enum call-state Sensor Entity. Each sensor follows only its phone through ringing, bridging and Assist. Its stable states are:

  • offline
  • idle
  • ringing
  • calling
  • remote_ringing
  • connecting
  • in_call
  • held
  • terminating

Attributes include stable endpoint identity plus active-call call_id, direction, ingress, peer_name and terminal_reason. ingress is trunk for provider/PBX calls and extension for locally originated SIP calls. Ordinary single-call automations do not need to read these fields.

The phone Device itself is the Home Assistant registry container; entities are its triggerable state/event surfaces. The per-phone Event Entity, durable sensor, WebSocket stream and card are all derived from the same backend call session.

DTMF during a connected call

Initial trunk extension selection and established-call DTMF are deliberately separate:

  • Digits used before routing select a phonebook extension and do not become in-call automation events.
  • During an HA-bridged established call, each negotiated key emits one dtmf occurrence while audio continues.
  • DTMF processing remains HA-side and adds no work to ESP firmware.

This supports actions such as opening a gate when a participant presses a key, without turning the keypad into a second routing state machine.

Dial a phonebook extension during initial trunk routing

This path does not need an automation:

  1. Open the target phone Device and set its Extension entity, for example 667 for the Test phone. A registered SIP account receives its extension through the Add phone or Reconfigure flow. A manual contact receives it in the optional extension field of voip_stack.add_contact.
  2. Reconfigure the trunk and select Collect extension with DTMF.
  3. Set a collection timeout and a normal fallback destination.
  4. Call the trunk and enter 667. The phonebook routes the call to Test.

Explicit digits are authoritative. A valid extension does not emit route_requested and cannot be replaced by a broad initial-routing automation. If the caller enters no digits, the optional automation decision and then the configured fallback are evaluated as described above.

Open a gate with in-call DTMF

During a connected call, this example presses a gate relay when the caller on the Front Door call presses 5:

alias: VoIP - Open front gate with DTMF 5
mode: parallel
max: 10
triggers:
  - trigger: event.received
    target:
      entity_id: event.casa_call
    options:
      event_type:
        - dtmf
conditions:
  - condition: state
    entity_id: event.casa_call
    attribute: digit
    state: "5"
  - condition: state
    entity_id: event.casa_call
    attribute: source_leg
    state: caller
  - condition: state
    entity_id: event.casa_call
    attribute: caller
    state: Front Door
actions:
  - action: button.press
    target:
      entity_id: button.front_gate

source_leg: caller prevents a key pressed by the receiving room phone from running the action. Replace the caller and button entities with values from your installation. The event also exposes callee, transport and ingress for more specific conditions.

Do not treat caller text or a DTMF digit as authentication on an untrusted network. For locks and gates, restrict SIP access to a trusted LAN, VPN or authenticated trunk and add any authorization conditions required by the installation.

Advanced concurrency controls

Every HA-owned logical call has one owner and a monotonic revision. Control changes such as route selection, destination replacement and ownership handoff advance the revision even if the visible state string stays the same. Delayed callbacks cannot restore an older state.

For expert scripts that manage several concurrent calls, call_id, expected_state and expected_sequence remain accepted. Explicit deadlines also remain available for multi-stage policies, but they are unnecessary for a normal no-answer forward.

Boundaries

  • Direct ESP-to-ESP calls remain peer-to-peer and observable only. HA cannot redirect media it does not own.
  • Initial selection and forward are HA B2BUA routing operations. voip_stack.transfer is the separate established-call SIP REFER operation.
  • Supported signaling includes INVITE, ACK, BYE, CANCEL, REGISTER, OPTIONS, authenticated text/plain MESSAGE, SIP INFO DTMF, RTP telephone-event, REFER/NOTIFY transfer, presence PUBLISH/SUBSCRIBE/NOTIFY, PRACK/100rel, session timers and peer-initiated UPDATE on HA-owned dialogs.
  • Offerless re-INVITE uses delayed offer/answer, with the local offer in the 200 OK and the peer answer in ACK.
  • Raw internal bus events are implementation plumbing for the Event Entities, not a second public automation API. Build new automations from the native entities and services above.