mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 03:03:17 +01:00
Use contact header for outgoing call transport (#151847)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
54
homeassistant/components/voip/store.py
Normal file
54
homeassistant/components/voip/store.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user