Skip to content

Commit f3c4288

Browse files
authored
Use contact header for outgoing call transport (home-assistant#151847)
1 parent 8db6505 commit f3c4288

File tree

6 files changed

+174
-9
lines changed

6 files changed

+174
-9
lines changed

homeassistant/components/voip/__init__.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from .const import CONF_SIP_PORT, DOMAIN
1919
from .devices import VoIPDevices
20+
from .store import VoipStore
2021
from .voip import HassVoipDatagramProtocol
2122

2223
PLATFORMS = (
@@ -35,6 +36,8 @@
3536
"async_unload_entry",
3637
]
3738

39+
type VoipConfigEntry = ConfigEntry[VoipStore]
40+
3841

3942
@dataclass
4043
class DomainData:
@@ -45,7 +48,7 @@ class DomainData:
4548
devices: VoIPDevices
4649

4750

48-
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
51+
async def async_setup_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> bool:
4952
"""Set up VoIP integration from a config entry."""
5053
# Make sure there is a valid user ID for VoIP in the config entry
5154
if (
@@ -59,9 +62,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
5962
entry, data={**entry.data, "user": voip_user.id}
6063
)
6164

65+
entry.runtime_data = VoipStore(hass, entry.entry_id)
6266
sip_port = entry.options.get(CONF_SIP_PORT, SIP_PORT)
6367
devices = VoIPDevices(hass, entry)
64-
devices.async_setup()
68+
await devices.async_setup()
6569
transport, protocol = await _create_sip_server(
6670
hass,
6771
lambda: HassVoipDatagramProtocol(hass, devices),
@@ -102,7 +106,7 @@ async def _create_sip_server(
102106
return transport, protocol
103107

104108

105-
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
109+
async def async_unload_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> bool:
106110
"""Unload VoIP."""
107111
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
108112
_LOGGER.debug("Shutting down VoIP server")
@@ -121,9 +125,11 @@ async def async_remove_config_entry_device(
121125
return True
122126

123127

124-
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
128+
async def async_remove_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> None:
125129
"""Remove VoIP entry."""
126130
if "user" in entry.data and (
127131
user := await hass.auth.async_get_user(entry.data["user"])
128132
):
129133
await hass.auth.async_remove_user(user)
134+
135+
await entry.runtime_data.async_remove()

homeassistant/components/voip/assist_satellite.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ def __init__(
119119
AssistSatelliteEntity.__init__(self)
120120
RtpDatagramProtocol.__init__(self)
121121

122+
_LOGGER.debug("Assist satellite with device: %s", voip_device)
123+
122124
self.config_entry = config_entry
123125

124126
self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue()
@@ -254,7 +256,7 @@ async def _do_announce(
254256
)
255257

256258
try:
257-
# VoIP ID is SIP header
259+
# VoIP ID is SIP header - This represents what is set as the To header
258260
destination_endpoint = SipEndpoint(self.voip_device.voip_id)
259261
except ValueError:
260262
# VoIP ID is IP address
@@ -269,10 +271,12 @@ async def _do_announce(
269271

270272
# Make the call
271273
sip_protocol: SipDatagramProtocol = self.hass.data[DOMAIN].protocol
274+
_LOGGER.debug("Outgoing call to contact %s", self.voip_device.contact)
272275
call_info = sip_protocol.outgoing_call(
273276
source=source_endpoint,
274277
destination=destination_endpoint,
275278
rtp_port=self._rtp_port,
279+
contact=self.voip_device.contact,
276280
)
277281

278282
# Check if caller didn't pick up

homeassistant/components/voip/const.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Constants for the Voice over IP integration."""
22

3+
from typing import Final
4+
35
DOMAIN = "voip"
46

57
RATE = 16000
@@ -14,3 +16,5 @@
1416

1517
CONF_SIP_PORT = "sip_port"
1618
CONF_SIP_USER = "sip_user"
19+
20+
STORAGE_VER: Final = 1

homeassistant/components/voip/devices.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,20 @@
44

55
from collections.abc import Callable, Iterator
66
from dataclasses import dataclass, field
7+
import logging
78
from typing import Any
89

910
from voip_utils import CallInfo, VoipDatagramProtocol
11+
from voip_utils.sip import SipEndpoint
1012

1113
from homeassistant.config_entries import ConfigEntry
1214
from homeassistant.core import Event, HomeAssistant, callback
1315
from homeassistant.helpers import device_registry as dr, entity_registry as er
1416

1517
from .const import DOMAIN
18+
from .store import DeviceContact, DeviceContacts, VoipStore
19+
20+
_LOGGER = logging.getLogger(__name__)
1621

1722

1823
@dataclass
@@ -24,6 +29,7 @@ class VoIPDevice:
2429
is_active: bool = False
2530
update_listeners: list[Callable[[VoIPDevice], None]] = field(default_factory=list)
2631
protocol: VoipDatagramProtocol | None = None
32+
contact: SipEndpoint | None = None
2733

2834
@callback
2935
def set_is_active(self, active: bool) -> None:
@@ -80,9 +86,9 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
8086
self.config_entry = config_entry
8187
self._new_device_listeners: list[Callable[[VoIPDevice], None]] = []
8288
self.devices: dict[str, VoIPDevice] = {}
89+
self.device_store: VoipStore = config_entry.runtime_data
8390

84-
@callback
85-
def async_setup(self) -> None:
91+
async def async_setup(self) -> None:
8692
"""Set up devices."""
8793
for device in dr.async_entries_for_config_entry(
8894
dr.async_get(self.hass), self.config_entry.entry_id
@@ -92,9 +98,13 @@ def async_setup(self) -> None:
9298
)
9399
if voip_id is None:
94100
continue
101+
devices_data: DeviceContacts = await self.device_store.async_load_devices()
102+
device_data: DeviceContact | None = devices_data.get(voip_id)
103+
_LOGGER.debug("Loaded device data for %s: %s", voip_id, device_data)
95104
self.devices[voip_id] = VoIPDevice(
96105
voip_id=voip_id,
97106
device_id=device.id,
107+
contact=SipEndpoint(device_data.contact) if device_data else None,
98108
)
99109

100110
@callback
@@ -185,12 +195,29 @@ def entity_migrator(entry: er.RegistryEntry) -> dict[str, Any] | None:
185195
)
186196

187197
if voip_device is not None:
198+
if (
199+
call_info.contact_endpoint is not None
200+
and voip_device.contact != call_info.contact_endpoint
201+
):
202+
# Update VOIP device with contact information from call info
203+
voip_device.contact = call_info.contact_endpoint
204+
self.hass.async_create_task(
205+
self.device_store.async_update_device(
206+
voip_id, call_info.contact_endpoint.sip_header
207+
)
208+
)
188209
return voip_device
189210

190211
voip_device = self.devices[voip_id] = VoIPDevice(
191-
voip_id=voip_id,
192-
device_id=device.id,
212+
voip_id=voip_id, device_id=device.id, contact=call_info.contact_endpoint
193213
)
214+
if call_info.contact_endpoint is not None:
215+
self.hass.async_create_task(
216+
self.device_store.async_update_device(
217+
voip_id, call_info.contact_endpoint.sip_header
218+
)
219+
)
220+
194221
for listener in self._new_device_listeners:
195222
listener(voip_device)
196223

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""VOIP contact storage."""
2+
3+
from dataclasses import dataclass
4+
import logging
5+
6+
from homeassistant.core import HomeAssistant
7+
from homeassistant.helpers.storage import Store
8+
9+
from .const import STORAGE_VER
10+
11+
_LOGGER = logging.getLogger(__name__)
12+
13+
14+
@dataclass
15+
class DeviceContact:
16+
"""Device contact data."""
17+
18+
contact: str
19+
20+
21+
class DeviceContacts(dict[str, DeviceContact]):
22+
"""Map of device contact data."""
23+
24+
25+
class VoipStore(Store):
26+
"""Store for VOIP device contact information."""
27+
28+
def __init__(self, hass: HomeAssistant, storage_key: str) -> None:
29+
"""Initialize the VOIP Storage."""
30+
super().__init__(hass, STORAGE_VER, f"voip-{storage_key}")
31+
32+
async def async_load_devices(self) -> DeviceContacts:
33+
"""Load data from store as DeviceContacts."""
34+
raw_data: dict[str, dict[str, str]] = await self.async_load() or {}
35+
return self._dict_to_devices(raw_data)
36+
37+
async def async_update_device(self, voip_id: str, contact_header: str) -> None:
38+
"""Update the device store with the contact information."""
39+
_LOGGER.debug("Saving new VOIP device %s contact %s", voip_id, contact_header)
40+
devices_data: DeviceContacts = await self.async_load_devices()
41+
_LOGGER.debug("devices_data: %s", devices_data)
42+
device_data: DeviceContact | None = devices_data.get(voip_id)
43+
if device_data is not None:
44+
device_data.contact = contact_header
45+
else:
46+
devices_data[voip_id] = DeviceContact(contact_header)
47+
await self.async_save(devices_data)
48+
_LOGGER.debug("Saved new VOIP device contact")
49+
50+
def _dict_to_devices(self, raw_data: dict[str, dict[str, str]]) -> DeviceContacts:
51+
contacts = DeviceContacts()
52+
for k, v in (raw_data or {}).items():
53+
contacts[k] = DeviceContact(**v)
54+
return contacts

tests/components/voip/test_devices.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22

33
from __future__ import annotations
44

5+
from unittest.mock import AsyncMock
6+
57
import pytest
68
from voip_utils import CallInfo
9+
from voip_utils.sip import SipEndpoint
710

811
from homeassistant.components.voip import DOMAIN
912
from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices
13+
from homeassistant.components.voip.store import VoipStore
1014
from homeassistant.core import HomeAssistant
1115
from homeassistant.helpers import device_registry as dr, entity_registry as er
1216

@@ -63,6 +67,72 @@ async def test_device_registry_info_from_unknown_phone(
6367
assert device.sw_version is None
6468

6569

70+
async def test_device_registry_info_update_contact(
71+
hass: HomeAssistant,
72+
voip_devices: VoIPDevices,
73+
call_info: CallInfo,
74+
device_registry: dr.DeviceRegistry,
75+
) -> None:
76+
"""Test info in device registry."""
77+
voip_device = voip_devices.async_get_or_create(call_info)
78+
assert not voip_device.async_allow_call(hass)
79+
80+
device = device_registry.async_get_device(
81+
identifiers={(DOMAIN, call_info.caller_endpoint.uri)}
82+
)
83+
assert device is not None
84+
assert device.name == call_info.caller_endpoint.host
85+
assert device.manufacturer == "Grandstream"
86+
assert device.model == "HT801"
87+
assert device.sw_version == "1.0.17.5"
88+
89+
# Test we update the device if the fw updates
90+
call_info.headers["user-agent"] = "Grandstream HT801 2.0.0.0"
91+
call_info.contact_endpoint = SipEndpoint("Test <sip:example.com:5061>")
92+
voip_device = voip_devices.async_get_or_create(call_info)
93+
94+
assert voip_device.contact == SipEndpoint("Test <sip:example.com:5061>")
95+
assert not voip_device.async_allow_call(hass)
96+
97+
device = device_registry.async_get_device(
98+
identifiers={(DOMAIN, call_info.caller_endpoint.uri)}
99+
)
100+
assert device.sw_version == "2.0.0.0"
101+
102+
103+
async def test_device_load_contact(
104+
hass: HomeAssistant,
105+
call_info: CallInfo,
106+
config_entry: MockConfigEntry,
107+
device_registry: dr.DeviceRegistry,
108+
) -> None:
109+
"""Test loading contact endpoint from Store."""
110+
voip_id = call_info.caller_endpoint.uri
111+
mock_store = VoipStore(hass, "test")
112+
mock_store.async_load = AsyncMock(
113+
return_value={voip_id: {"contact": "Test <sip:example.com:5061>"}}
114+
)
115+
116+
config_entry.runtime_data = mock_store
117+
118+
# Initialize voip device
119+
device_registry.async_get_or_create(
120+
config_entry_id=config_entry.entry_id,
121+
identifiers={(DOMAIN, voip_id)},
122+
name=call_info.caller_endpoint.host,
123+
manufacturer="Grandstream",
124+
model="HT801",
125+
sw_version="1.0.0.0",
126+
configuration_url=f"http://{call_info.caller_ip}",
127+
)
128+
129+
voip = VoIPDevices(hass, config_entry)
130+
131+
await voip.async_setup()
132+
voip_device = voip.devices.get(voip_id)
133+
assert voip_device.contact == SipEndpoint("Test <sip:example.com:5061>")
134+
135+
66136
async def test_remove_device_registry_entry(
67137
hass: HomeAssistant,
68138
voip_device: VoIPDevice,

0 commit comments

Comments
 (0)