Use contact header for outgoing call transport (#151847)

This commit is contained in:
Jamin
2025-10-14 07:36:31 -05:00
committed by GitHub
parent 8db6505a97
commit f3c4288026
6 changed files with 174 additions and 9 deletions

View File

@@ -17,6 +17,7 @@ from homeassistant.helpers import device_registry as dr
from .const import CONF_SIP_PORT, DOMAIN
from .devices import VoIPDevices
from .store import VoipStore
from .voip import HassVoipDatagramProtocol
PLATFORMS = (
@@ -35,6 +36,8 @@ __all__ = [
"async_unload_entry",
]
type VoipConfigEntry = ConfigEntry[VoipStore]
@dataclass
class DomainData:
@@ -45,7 +48,7 @@ class DomainData:
devices: VoIPDevices
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> bool:
"""Set up VoIP integration from a config entry."""
# Make sure there is a valid user ID for VoIP in the config entry
if (
@@ -59,9 +62,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry, data={**entry.data, "user": voip_user.id}
)
entry.runtime_data = VoipStore(hass, entry.entry_id)
sip_port = entry.options.get(CONF_SIP_PORT, SIP_PORT)
devices = VoIPDevices(hass, entry)
devices.async_setup()
await devices.async_setup()
transport, protocol = await _create_sip_server(
hass,
lambda: HassVoipDatagramProtocol(hass, devices),
@@ -102,7 +106,7 @@ async def _create_sip_server(
return transport, protocol
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> bool:
"""Unload VoIP."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
_LOGGER.debug("Shutting down VoIP server")
@@ -121,9 +125,11 @@ async def async_remove_config_entry_device(
return True
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def async_remove_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> None:
"""Remove VoIP entry."""
if "user" in entry.data and (
user := await hass.auth.async_get_user(entry.data["user"])
):
await hass.auth.async_remove_user(user)
await entry.runtime_data.async_remove()

View File

@@ -119,6 +119,8 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
AssistSatelliteEntity.__init__(self)
RtpDatagramProtocol.__init__(self)
_LOGGER.debug("Assist satellite with device: %s", voip_device)
self.config_entry = config_entry
self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue()
@@ -254,7 +256,7 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
)
try:
# VoIP ID is SIP header
# VoIP ID is SIP header - This represents what is set as the To header
destination_endpoint = SipEndpoint(self.voip_device.voip_id)
except ValueError:
# VoIP ID is IP address
@@ -269,10 +271,12 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
# Make the call
sip_protocol: SipDatagramProtocol = self.hass.data[DOMAIN].protocol
_LOGGER.debug("Outgoing call to contact %s", self.voip_device.contact)
call_info = sip_protocol.outgoing_call(
source=source_endpoint,
destination=destination_endpoint,
rtp_port=self._rtp_port,
contact=self.voip_device.contact,
)
# Check if caller didn't pick up

View File

@@ -1,5 +1,7 @@
"""Constants for the Voice over IP integration."""
from typing import Final
DOMAIN = "voip"
RATE = 16000
@@ -14,3 +16,5 @@ RTP_AUDIO_SETTINGS = {
CONF_SIP_PORT = "sip_port"
CONF_SIP_USER = "sip_user"
STORAGE_VER: Final = 1

View File

@@ -4,15 +4,20 @@ from __future__ import annotations
from collections.abc import Callable, Iterator
from dataclasses import dataclass, field
import logging
from typing import Any
from voip_utils import CallInfo, VoipDatagramProtocol
from voip_utils.sip import SipEndpoint
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import DOMAIN
from .store import DeviceContact, DeviceContacts, VoipStore
_LOGGER = logging.getLogger(__name__)
@dataclass
@@ -24,6 +29,7 @@ class VoIPDevice:
is_active: bool = False
update_listeners: list[Callable[[VoIPDevice], None]] = field(default_factory=list)
protocol: VoipDatagramProtocol | None = None
contact: SipEndpoint | None = None
@callback
def set_is_active(self, active: bool) -> None:
@@ -80,9 +86,9 @@ class VoIPDevices:
self.config_entry = config_entry
self._new_device_listeners: list[Callable[[VoIPDevice], None]] = []
self.devices: dict[str, VoIPDevice] = {}
self.device_store: VoipStore = config_entry.runtime_data
@callback
def async_setup(self) -> None:
async def async_setup(self) -> None:
"""Set up devices."""
for device in dr.async_entries_for_config_entry(
dr.async_get(self.hass), self.config_entry.entry_id
@@ -92,9 +98,13 @@ class VoIPDevices:
)
if voip_id is None:
continue
devices_data: DeviceContacts = await self.device_store.async_load_devices()
device_data: DeviceContact | None = devices_data.get(voip_id)
_LOGGER.debug("Loaded device data for %s: %s", voip_id, device_data)
self.devices[voip_id] = VoIPDevice(
voip_id=voip_id,
device_id=device.id,
contact=SipEndpoint(device_data.contact) if device_data else None,
)
@callback
@@ -185,12 +195,29 @@ class VoIPDevices:
)
if voip_device is not None:
if (
call_info.contact_endpoint is not None
and voip_device.contact != call_info.contact_endpoint
):
# Update VOIP device with contact information from call info
voip_device.contact = call_info.contact_endpoint
self.hass.async_create_task(
self.device_store.async_update_device(
voip_id, call_info.contact_endpoint.sip_header
)
)
return voip_device
voip_device = self.devices[voip_id] = VoIPDevice(
voip_id=voip_id,
device_id=device.id,
voip_id=voip_id, device_id=device.id, contact=call_info.contact_endpoint
)
if call_info.contact_endpoint is not None:
self.hass.async_create_task(
self.device_store.async_update_device(
voip_id, call_info.contact_endpoint.sip_header
)
)
for listener in self._new_device_listeners:
listener(voip_device)

View File

@@ -0,0 +1,54 @@
"""VOIP contact storage."""
from dataclasses import dataclass
import logging
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from .const import STORAGE_VER
_LOGGER = logging.getLogger(__name__)
@dataclass
class DeviceContact:
"""Device contact data."""
contact: str
class DeviceContacts(dict[str, DeviceContact]):
"""Map of device contact data."""
class VoipStore(Store):
"""Store for VOIP device contact information."""
def __init__(self, hass: HomeAssistant, storage_key: str) -> None:
"""Initialize the VOIP Storage."""
super().__init__(hass, STORAGE_VER, f"voip-{storage_key}")
async def async_load_devices(self) -> DeviceContacts:
"""Load data from store as DeviceContacts."""
raw_data: dict[str, dict[str, str]] = await self.async_load() or {}
return self._dict_to_devices(raw_data)
async def async_update_device(self, voip_id: str, contact_header: str) -> None:
"""Update the device store with the contact information."""
_LOGGER.debug("Saving new VOIP device %s contact %s", voip_id, contact_header)
devices_data: DeviceContacts = await self.async_load_devices()
_LOGGER.debug("devices_data: %s", devices_data)
device_data: DeviceContact | None = devices_data.get(voip_id)
if device_data is not None:
device_data.contact = contact_header
else:
devices_data[voip_id] = DeviceContact(contact_header)
await self.async_save(devices_data)
_LOGGER.debug("Saved new VOIP device contact")
def _dict_to_devices(self, raw_data: dict[str, dict[str, str]]) -> DeviceContacts:
contacts = DeviceContacts()
for k, v in (raw_data or {}).items():
contacts[k] = DeviceContact(**v)
return contacts

View File

@@ -2,11 +2,15 @@
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from voip_utils import CallInfo
from voip_utils.sip import SipEndpoint
from homeassistant.components.voip import DOMAIN
from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices
from homeassistant.components.voip.store import VoipStore
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -63,6 +67,72 @@ async def test_device_registry_info_from_unknown_phone(
assert device.sw_version is None
async def test_device_registry_info_update_contact(
hass: HomeAssistant,
voip_devices: VoIPDevices,
call_info: CallInfo,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test info in device registry."""
voip_device = voip_devices.async_get_or_create(call_info)
assert not voip_device.async_allow_call(hass)
device = device_registry.async_get_device(
identifiers={(DOMAIN, call_info.caller_endpoint.uri)}
)
assert device is not None
assert device.name == call_info.caller_endpoint.host
assert device.manufacturer == "Grandstream"
assert device.model == "HT801"
assert device.sw_version == "1.0.17.5"
# Test we update the device if the fw updates
call_info.headers["user-agent"] = "Grandstream HT801 2.0.0.0"
call_info.contact_endpoint = SipEndpoint("Test <sip:example.com:5061>")
voip_device = voip_devices.async_get_or_create(call_info)
assert voip_device.contact == SipEndpoint("Test <sip:example.com:5061>")
assert not voip_device.async_allow_call(hass)
device = device_registry.async_get_device(
identifiers={(DOMAIN, call_info.caller_endpoint.uri)}
)
assert device.sw_version == "2.0.0.0"
async def test_device_load_contact(
hass: HomeAssistant,
call_info: CallInfo,
config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test loading contact endpoint from Store."""
voip_id = call_info.caller_endpoint.uri
mock_store = VoipStore(hass, "test")
mock_store.async_load = AsyncMock(
return_value={voip_id: {"contact": "Test <sip:example.com:5061>"}}
)
config_entry.runtime_data = mock_store
# Initialize voip device
device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, voip_id)},
name=call_info.caller_endpoint.host,
manufacturer="Grandstream",
model="HT801",
sw_version="1.0.0.0",
configuration_url=f"http://{call_info.caller_ip}",
)
voip = VoIPDevices(hass, config_entry)
await voip.async_setup()
voip_device = voip.devices.get(voip_id)
assert voip_device.contact == SipEndpoint("Test <sip:example.com:5061>")
async def test_remove_device_registry_entry(
hass: HomeAssistant,
voip_device: VoIPDevice,