ESPHome VoIP Stack

August 29, 2026 ยท View on GitHub

A native SIP phone component for ESPHome. voip_stack turns an ESP32 device into a real SIP endpoint: SIP signaling over UDP or TCP, SDP offer/answer, RTP media, a local phonebook and dial plan, explicit per-direction PCM negotiation, and a full set of ESPHome entities, triggers, actions and conditions on top.

It binds to standard ESPHome microphone and speaker components, so the audio source can be anything from a bare I2S MEMS microphone to the echo-cancelled output of esphome-audio-stack. The component is a complete SIP phone on its own; the optional Home Assistant integration from esphome-intercom adds central phonebook management, call routing, a softphone/B2BUA and a Lovelace card on top of the same devices.

What this is

ESPHome has excellent building blocks for voice devices, but no native way to make two devices call each other or carry a two-way conversation with a phone, PBX extension, Home Assistant dashboard, or another ESP. voip_stack fills that layer with the protocol the rest of the telephony world already speaks: SIP for call control, SDP for capability negotiation, RTP for media.

   ESPHome microphone / microphone_source        ESPHome speaker
   (native i2s_audio, esp_audio_stack,           (native i2s_audio,
    any processed source)                         esp_audio_stack, mixer...)
                 \                                   /
                  \                                 /
                   +--------- voip_stack ----------+
                   |  SIP signaling (UDP or TCP)   |
                   |  SDP offer/answer             |
                   |  RTP media (always UDP)       |
                   |  jitter buffer                |
                   |  call finite-state machine    |
                   |  phonebook / dial plan        |
                   |  endpoint identity publishing |
                   +-------------------------------+
                     |            |            |
                 triggers      actions      entities

Two scope decisions shape the component:

voip_stack does not own audio hardware or echo cancellation. It consumes microphone and speaker interfaces. When software AEC/AFE is needed, esp_audio_stack owns the audio graph and hands voip_stack a clean post-processor microphone. Legacy processing options on voip_stack are intentionally gone.

Devices are peers, not trunk subscribers. ESP devices call each other and Home Assistant directly by SIP; they never register to a PBX or provider trunk. If an installation has a trunk, Home Assistant terminates it and routes calls to ESP devices as normal SIP phones.

What it gives you

A real SIP endpoint. INVITE/ACK/BYE over UDP or TCP signaling, SDP offer/answer, RTP media that stays UDP in both signaling modes, and a jitter buffer on the receive path.

Three supported media roles, inferred from YAML.

YAML wiringPublished modeRuntime behavior
microphone + speakerfull_duplexSends mic audio, plays remote audio.
microphone onlymic_onlySends mic audio; incoming audio is ignored.
speaker onlyspeaker_onlyPlays incoming audio; no TX task is created.

Mic-only and speaker-only are not degraded modes. They exist for paging speakers, monitor devices, split installations, and hardware that physically has one direction.

Explicit per-direction PCM negotiation. TX and RX formats are declared independently: sample rate from 8000 to 48000 Hz, RTP-mappable PCM format s16le/s24le/s24le_in_s32, 1 or 2 channels, packet time 10/16/20/32 ms. Devices advertise their real capabilities instead of pretending everything is 8 kHz telephony audio.

Auto-derivation from the audio graph. audio.tx: auto reads the configured microphone/microphone_source and derives rate, format and channels from what that source actually publishes, with validation errors when the source cannot answer.

A phonebook that is a dial plan, not a gatekeeper. Contacts define where outbound calls go. Inbound calls are not rejected just because the caller is unknown; the INVITE carries caller and destination identity, and HA-side resolution handles routed/NAT installs.

Self-describing devices. The component publishes text sensors for state, transport, endpoint identity, destination, caller, contacts, SIP snapshot and last terminal reason.

Honest failure taxonomy. The FSM distinguishes BUSY, DECLINED, CANCELLED, MEDIA_INCOMPATIBLE, TRANSPORT_UNREACHABLE and AUTH_REQUIRED_UNSUPPORTED.

Compile-time pruning. A speaker-only build contains no VoIP TX path and a mic-only build contains no RX-to-speaker path.

Optional ESP32-P4 video adapters. JPEG and H.264 remain separate compile-time profiles. The camera component owns capture and borrowed frames, the codec-specific source owns encoding or compressed-frame forwarding, and p4_video_renderer owns receive decode and panel presentation. Audio-only builds do not include these components or their codec dependencies.

Full ESPHome automation surface. Triggers, actions and conditions are normal ESPHome automations.

Scenarios

GoalShape
Two ESP intercoms calling each other on a LAN, no servervoip_stack + static contacts, direct SIP/UDP
Doorbell that rings phones and HA dashboardsvoip_stack + the HA integration from esphome-intercom
Voice assistant device that is also an intercomesp_audio_stack + voip_stack
Paging speaker in a workshopspeaker-only voip_stack
Baby monitor / listen-in devicemic-only voip_stack
PBX/provider trunk reaching ESP devicesHA terminates the trunk and routes; ESPs stay plain SIP peers
Offline installation with a fixed dial planstatic_contacts, no HA required

Core concepts

Endpoint. The published identity of a device: name, optional host/IP, SIP port, RTP port, signaling transport, capability mode, and advertised TX/RX PCM capabilities. The endpoint text sensor is the authoritative record Home Assistant consumes.

Capability mode. Derived from wiring. It gates task creation at runtime and code inclusion at compile time.

Phonebook. The outbound dial plan: an ordered list of contacts the device can call, navigated by next_contact/prev_contact or addressed by name. Sources are static YAML contacts, runtime actions, and the HA-managed roster.

Audio format contract. audio.tx describes what the device sends, audio.rx what it accepts. The internal no-audio placeholder is never used to repair missing endpoint formats.

Call FSM. One explicit state machine drives the lifecycle. Every state is observable through the state text sensor, triggers and conditions. Terminal outcomes are published through last_reason.

Installation

external_components:
  - source: github://n-IA-hane/esphome-voip-stack@v2026.9.0
    components: [voip_stack]

Requirements: ESP-IDF framework. PSRAM is recommended for full-duplex profiles and required by co-resident AEC/AFE processing. RTP media requires UDP reachability between peers in both signaling modes.

When pairing with the audio stack, pull both:

external_components:
  - source: github://n-IA-hane/esphome-audio-stack@v2026.9.0
    components: [esp_audio_stack, esp_aec]
  - source: github://n-IA-hane/esphome-voip-stack@v2026.9.0
    components: [voip_stack]

Audio wiring

Standalone native ESPHome audio

Use native ESPHome microphone and speaker components when the hardware already returns processed audio, or for simple full-duplex tests:

voip_stack:
  id: phone
  microphone_source:
    microphone: native_mic
    bits_per_sample: 16
    channels: [0]
  speaker: native_speaker
  audio:
    tx: { sample_rate: 48000, pcm_format: s16le, channels: 1, frame_ms: 10 }
    rx: { sample_rate: 48000, pcm_format: s16le, channels: 1, frame_ms: 10 }

Use microphone_source: when the ESPHome microphone is wider than the VoIP stream and voip_stack must select a channel or bit depth. In the example above the I2S microphone is 32-bit stereo, while the SIP/RTP TX stream is 16-bit mono, so microphone_source selects one channel and converts the sample width before VoIP sends it.

Use microphone: when the referenced microphone already exposes the exact PCM stream you want to advertise in audio.tx. This is the normal shape with esp_audio_stack, because its microphone platform already publishes the post-AEC/AFE clean microphone stream.

Audio stack facade

This is the maintained path when software AEC/AFE, media player, Voice Assistant, Micro Wake Word or multiple consumers share the backend:

external_components:
  - source: github://n-IA-hane/esphome-audio-stack@v2026.9.0
    components: [esp_audio_stack, esp_aec]
  - source: github://n-IA-hane/esphome-voip-stack@v2026.9.0
    components: [voip_stack]

esp_aec:
  id: aec_processor
  mode: sr_low_cost

esp_audio_stack:
  id: audio_stack
  processor_id: aec_processor
  sample_rate: 48000
  output_sample_rate: 16000

microphone:
  - platform: esp_audio_stack
    id: clean_mic
    esp_audio_stack_id: audio_stack

speaker:
  - platform: esp_audio_stack
    id: stack_speaker
    esp_audio_stack_id: audio_stack

voip_stack:
  id: phone
  microphone: clean_mic
  speaker: stack_speaker
  audio:
    tx: auto
    rx: auto

During a call, the far end hears near-end speech after echo cancellation. Wake word keeps working locally because the same clean stream feeds it.

For the full Espressif AFE pipeline, import esp_afe instead of esp_aec and point processor_id at that processor:

external_components:
  - source: github://n-IA-hane/esphome-audio-stack@v2026.9.0
    components: [esp_audio_stack, esp_afe]
  - source: github://n-IA-hane/esphome-voip-stack@v2026.9.0
    components: [voip_stack]

esp_afe:
  id: afe_processor
  type: fd
  mode: high_perf

esp_audio_stack:
  id: audio_stack
  processor_id: afe_processor
  sample_rate: 48000
  output_sample_rate: 16000

Microphone-only and speaker-only

# Paging speaker
voip_stack:
  id: pager
  speaker: local_speaker

# Listen-in device
voip_stack:
  id: monitor
  microphone: processed_mic

Audio format negotiation

Per-direction format blocks accept auto per field or as the whole block:

FieldValuesDefault
sample_rate8000, 12000, 16000, 24000, 32000, 44100, 48000auto
pcm_formats16le, s24le, s24le_in_s32 (s32le is rejected because RTP has no mapping)auto
channels1, 2auto
frame_ms10, 16, 20, 32auto

tx_formats / rx_formats declare up to seven additional advertised formats per direction. Extra entries are explicit. TX extras may only vary frame_ms relative to audio.tx, because the microphone path only reframes its native stream; RX extras may describe other supported speaker-side formats. Validation rejects the eighth extra entry.

RTP packet sizes are guarded by udp_max_payload (default 1200 bytes), enforced at validation.

Transport

SIP is the only call-control protocol. transport selects the SIP signaling transport; RTP media remains UDP in both modes:

voip_stack:
  id: phone
  transport: tcp        # or udp
  sip_port: 5060
  rtp_port: 40000

TCP signaling is useful where UDP SIP is filtered or unreliable; per-contact transport allows mixing.

Phonebook and routing

voip_stack:
  id: phone
  static_contacts:
    - name: Workshop
      ip: 192.168.1.44
      port: 5060
      rtp_port: 40000
      transport: udp
    - name: Front Gate

name is required; everything else is optional. Static contacts serve offline installs, diagnostics and direct SIP peers. Normal installs use the HA-managed roster through ha_phonebook_text_sensor_id.

Inbound INVITEs carry caller and destination identity and are not rejected for being unknown. ESP devices never register to an HA or provider trunk; trunk numbers and inbound DTMF routes are mapped by HA to local phonebook targets.

ESP devices can also publish optional HA-managed group membership:

voip_stack:
  id: phone
  conference_groups: CG Home, CG Workshop
  conference_ring: true
  ring_groups: RG Home

text:
  - platform: voip_stack
    type: ring_groups
    name: VoIP Ring Groups
  - platform: voip_stack
    type: conference_groups
    name: VoIP Conference Groups

switch:
  - platform: voip_stack
    conference_ring:
      name: VoIP Ring On Conference

conference_groups and ring_groups accept one name or a comma-separated list. conference_groups means calling the group joins an HA-mixed conference immediately. conference_ring makes this device ring when another member starts one of its conference groups; with it disabled, the device can still join by calling the conference contact manually. ring_groups means calling the group makes all members ring and the first answer wins. All group routing is handled by the Home Assistant voip_stack integration; from the ESP point of view the group is just another phonebook contact. They require the HA integration release that documents group routing support.

The ESP component does not implement a PBX. It only publishes local membership metadata through dedicated HA entities and calls the resulting phonebook contact by name. Home Assistant materializes the group entries, performs SIP forking for ring groups, mixes conference rooms, and pushes the updated roster back to the ESP devices.

Home Assistant inbound routing

Provider/PBX trunks and automation routing belong to the optional Home Assistant integration, never to ESP firmware. That integration keeps the same shared phonebook as its canonical dial plan and offers two explicit trunk inbound modes:

  • Direct immediately follows the configured default phonebook target.
  • DTMF answers the trunk leg and lets explicit digits select a phonebook extension; no digits use the configured default target.

An unknown explicit extension fails instead of falling through to another destination. A separate experimental switch may expose a short Home Assistant automation decision before Direct routing or the no-digits DTMF fallback. Explicit DTMF digits always keep priority, and disabling that switch restores the ordinary phonebook route.

After HA starts ringing, an ordinary HA state trigger with for: can redirect the same unanswered call to another phonebook destination such as an ESP, registered phone or Assist. The ESP remains a normal SIP peer and contains no parallel automation dial plan.

See the main project's Automation Dial Plan guide for the exact config-flow semantics and native HA automation examples.

Call lifecycle, triggers and conditions

FSM states: IDLE, CALLING, REMOTE_RINGING, RINGING, CONNECTING, IN_CALL, TERMINATING, and terminal outcomes BUSY, DECLINED, CANCELLED, MEDIA_INCOMPATIBLE, TRANSPORT_UNREACHABLE, AUTH_REQUIRED_UNSUPPORTED.

Triggers:

TriggerFires
on_ringingInbound call starts ringing locally; provides peer.
on_callingOutbound call enters CALLING; provides peer.
on_dest_ringingRemote side reports ringing; provides peer.
on_in_callMedia is established; provides peer.
on_video_startNegotiated video media becomes active. Audio-only calls do not fire it.
on_video_endActive video media ends or is removed from an established call.
on_hangupCall ends normally; provides peer and reason.
on_call_failedCall ends in a failure terminal state; provides peer and reason.
on_idleFSM returns to idle.
on_incoming_callInbound INVITE parsed; provides call_id, caller, callee, uri.
on_outgoing_callOutbound call placed.
on_bridge_requestAn ESP-originated route explicitly targets the HA peer.
on_destination_changedSelected destination changed; provides destination.
on_phonebook_updateContact list changed; provides destination.

Conditions: voip_stack.is_idle, voip_stack.is_ringing, voip_stack.is_in_call, voip_stack.is_calling, voip_stack.is_incoming, voip_stack.destination_is, and voip_stack.is_ha_destination.

Actions

Call control:

ActionMeaning
voip_stack.startCall the selected destination.
voip_stack.stopHang up / cancel.
voip_stack.call_toggleStart when idle, stop otherwise.
voip_stack.answer_callAnswer ringing inbound call.
voip_stack.decline_callDecline with optional reason.
voip_stack.callDial a destination string. Local phonebook matches go direct; unresolved targets route through HA.
voip_stack.set_remote_endpointPoint the next call at an explicit address.

Phonebook actions: voip_stack.next_contact, voip_stack.prev_contact, voip_stack.add_contact, voip_stack.remove_contact, voip_stack.set_contact, voip_stack.set_contacts, voip_stack.flush_contacts, voip_stack.update_contacts, voip_stack.set_roster_json, and voip_stack.set_ha_peer_name.

Audio and diagnostic actions: voip_stack.set_volume, voip_stack.set_mic_gain_db, and voip_stack.publish_entity_states.

Entities

voip_stack: alone enables the local SIP/RTP engine. HA discovery, card mirror state, editable groups and diagnostics are exposed by declaring the native platform entities you need:

PlatformTypeContent
text_sensorendpointShort endpoint identity record used by HA phonebook discovery.
text_sensorstateCurrent FSM state.
text_sensorcallerCurrent/last caller identity.
text_sensordestinationCurrently selected dial-plan target.
text_sensorlast_reasonLast terminal outcome.
text_sensorcontactsPublished local contact list.
text_sensortransportActive signaling transport.
text_sensorsip_snapshotDiagnostic snapshot of the SIP layer.
textring_groupsEditable comma-separated ring group memberships.
textconference_groupsEditable comma-separated conference group memberships.
switchconference_ringRing this endpoint when another participant starts one of its conference groups.

For HA-managed installs, include the integration package from esphome-intercom or declare the equivalent entities manually:

text_sensor:
  - platform: voip_stack
    type: endpoint
    name: VoIP Endpoint
  - platform: voip_stack
    type: state
    name: VoIP State
  - platform: voip_stack
    type: caller
    name: VoIP Caller
  - platform: voip_stack
    type: destination
    name: VoIP Destination
  - platform: voip_stack
    type: last_reason
    name: VoIP Last Reason
  - platform: voip_stack
    type: contacts
    name: VoIP Contacts

text:
  - platform: voip_stack
    type: ring_groups
    name: VoIP Ring Groups
  - platform: voip_stack
    type: conference_groups
    name: VoIP Conference Groups

switch:
  - platform: voip_stack
    conference_ring:
      name: VoIP Ring On Conference

Declared entities:

switch:
  - platform: voip_stack
    voip_stack_id: phone
    active: { name: Call Active }
    auto_answer: { name: Auto Answer }
    dnd: { name: Do Not Disturb }

number:
  - platform: voip_stack
    voip_stack_id: phone
    master_volume: { name: Call Volume }
    mic_gain: { name: Call Mic Gain }

button:
  - platform: voip_stack
    voip_stack_id: phone
    call: { name: Call }
    next_contact: { name: Next Contact }
    previous_contact: { name: Previous Contact }
    decline: { name: Decline }

Configuration reference

OptionDefaultMeaning
transportudpSIP signaling transport, udp or tcp. RTP is always UDP.
sip_port5060Local SIP port.
rtp_port40000Local RTP port.
udp_max_payload1200RTP payload budget.
microphone / microphone_sourcenoneTX audio source.
speakernoneRX audio sink.
audio.tx / audio.rxautoPer-direction PCM contract.
audio.tx_formats / audio.rx_formats[]Extra explicit capabilities; TX extras are packet-time reframes, RX extras may use other supported formats.
static_contacts[]YAML dial plan.
extension""Local SIP extension/user part.
conference_groups""Optional HA-managed conference group membership, single name or comma-separated list.
conference_ringfalseRing this device when another member starts its conference group.
ring_groups""Optional HA-managed ring group membership, single name or comma-separated list.
use_ha_as_first_contactfalsePin the HA peer at the top of the dial plan.
ha_phonebook_text_sensor_idnoneBind the HA phonebook sensor.
delete_contact_missing_fromnoneDrop absent roster contact after N updates.
ringing_timeout / calling_timeoutnoneGuard timers.
dc_offset_removalfalseRemove DC bias on TX.
audio_debugfalseVerbose audio-path diagnostics.
buffers_in_psramfalsePlace VoIP-owned buffers in PSRAM.
task_stacks_in_psramfalsePlace signaling and media-transport task stacks in PSRAM where supported.
audio_task_stacks_in_psramfalseIndependently place the realtime voip_tx/voip_rx task stacks in PSRAM.
tx_task_stack_size12288Realtime transmit task stack in bytes, minimum 4096. Change only from measured high-water marks.
rx_task_stack_size12288Realtime receive task stack in bytes, minimum 4096. Change only from measured high-water marks.
video.codecrequired with video:Compile exactly one video backend: jpeg or h264.
video.camera_idnoneStandard ESPHome camera JPEG source; valid only with codec: jpeg.
video.source / video.sinknoneEncoded source and/or sink matching the selected codec.
video.offer_payload_type26 / 103Static PT 26 for JPEG; dynamic PT 103 by default for H.264.
network_socket_headroom0Extra lwIP sockets reserved at validation.

Removed options rejected with migration guidance: processor_id, aec_reference_delay_ms, and the old aec: switch platform. Software audio processing belongs to esp_audio_stack.

Resource notes

  • The mic ring buffer and TX chunk exist only when a mic path is configured.
  • There is no speaker ring, speaker task or AEC reference buffer inside voip_stack; incoming audio is written directly to the configured ESPHome speaker.
  • buffers_in_psram affects only VoIP-owned staging buffers.
  • Keep RTP payloads inside udp_max_payload; raising it above the real path MTU trades a compile error for intermittent one-way audio.

Scope and known boundaries

  • No SIP registration or digest authentication. Calls that hit an auth challenge terminate in AUTH_REQUIRED_UNSUPPORTED.
  • SIP over UDP/TCP only; no TLS signaling and no SRTP.
  • RTP media is UDP in both signaling modes and requires UDP reachability.
  • Audio is uncompressed PCM within the supported rate/format matrix; there are no compressed codecs.
  • In-dialog re-INVITE, including ordinary hold or codec renegotiation, receives 488 Not Acceptable Here. The established dialog/media remains active and a later BYE still terminates the original call.
  • The phonebook is not an inbound allowlist. Any peer with network reachability may send an INVITE, so plaintext SIP/RTP belongs on a trusted LAN/VPN or behind firewall/SBC policy.

Provenance and license

This component is the SIP engine of the esphome-intercom platform, where it is exercised together with its Home Assistant integration, Lovelace card, maintained device YAMLs and full-experience packages. Higher layers remain in the intercom repository; this repository tracks the component itself, and SOURCE.md records its extraction origin and scope.

The repository also carries a small internal ring_buffer component used by voip_stack for CAPS-aware RTP/audio buffering. It is not a user-facing YAML component.

MIT license.