diff --git a/homeassistant/components/voice_assistant/pipeline.py b/homeassistant/components/voice_assistant/pipeline.py index 73f710afe67..b30e3b07474 100644 --- a/homeassistant/components/voice_assistant/pipeline.py +++ b/homeassistant/components/voice_assistant/pipeline.py @@ -401,6 +401,7 @@ class PipelineRun: tts_media = await media_source.async_resolve_media( self.hass, tts_media_id, + None, ) except Exception as src_error: _LOGGER.exception("Unexpected error during text to speech") diff --git a/homeassistant/components/voip/__init__.py b/homeassistant/components/voip/__init__.py index daf946f688e..dddb2723102 100644 --- a/homeassistant/components/voip/__init__.py +++ b/homeassistant/components/voip/__init__.py @@ -3,17 +3,21 @@ from __future__ import annotations import asyncio from collections.abc import Callable +from dataclasses import dataclass import logging from voip_utils import SIP_PORT from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from .const import DOMAIN +from .devices import VoIPDevices from .voip import HassVoipDatagramProtocol +PLATFORMS = (Platform.SWITCH,) _LOGGER = logging.getLogger(__name__) _IP_WILDCARD = "0.0.0.0" @@ -24,18 +28,26 @@ __all__ = [ ] +@dataclass +class DomainData: + """Domain data.""" + + transport: asyncio.DatagramTransport + devices: VoIPDevices + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up VoIP integration from a config entry.""" - ip_address = entry.data[CONF_IP_ADDRESS] - _LOGGER.debug( - "Listening for VoIP calls from %s (port=%s)", - ip_address, - SIP_PORT, - ) - hass.data[DOMAIN] = await _create_sip_server( + devices = VoIPDevices(hass, entry) + transport = await _create_sip_server( hass, - lambda: HassVoipDatagramProtocol(hass, {str(ip_address)}), + lambda: HassVoipDatagramProtocol(hass, devices), ) + _LOGGER.debug("Listening for VoIP calls on port %s", SIP_PORT) + + hass.data[DOMAIN] = DomainData(transport, devices) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -57,9 +69,15 @@ async def _create_sip_server( async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload VoIP.""" - transport = hass.data.pop(DOMAIN, None) - if transport is not None: - transport.close() + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): _LOGGER.debug("Shut down VoIP server") + hass.data.pop(DOMAIN).transport.close() + return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove config entry from a device.""" return True diff --git a/homeassistant/components/voip/config_flow.py b/homeassistant/components/voip/config_flow.py index ca9600bc9bb..2c9649d911d 100644 --- a/homeassistant/components/voip/config_flow.py +++ b/homeassistant/components/voip/config_flow.py @@ -1,26 +1,13 @@ """Config flow for VoIP integration.""" from __future__ import annotations -import logging from typing import Any -import voluptuous as vol - from homeassistant import config_entries -from homeassistant.const import CONF_IP_ADDRESS from homeassistant.data_entry_flow import FlowResult -from homeassistant.util import network from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_IP_ADDRESS): str, - } -) - class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for VoIP integration.""" @@ -35,18 +22,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="single_instance_allowed") if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) - - errors: dict = {} - if not network.is_ipv4_address(user_input[CONF_IP_ADDRESS]): - errors[CONF_IP_ADDRESS] = "invalid_ip_address" - return self.async_show_form( - step_id="user", - data_schema=STEP_USER_DATA_SCHEMA, - errors=errors, - ) + return self.async_show_form(step_id="user") return self.async_create_entry( title="Voice over IP", diff --git a/homeassistant/components/voip/devices.py b/homeassistant/components/voip/devices.py new file mode 100644 index 00000000000..3f853369b33 --- /dev/null +++ b/homeassistant/components/voip/devices.py @@ -0,0 +1,79 @@ +"""Class to manage devices.""" +from __future__ import annotations + +from collections.abc import Callable + +from voip_utils import CallInfo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .const import DOMAIN + + +class VoIPDevices: + """Class to store devices.""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize VoIP devices.""" + self.hass = hass + self.config_entry = config_entry + self._new_device_listeners: list[Callable[[dr.DeviceEntry], None]] = [] + + @callback + def async_add_new_device_listener( + self, listener: Callable[[dr.DeviceEntry], None] + ) -> None: + """Add a new device listener.""" + self._new_device_listeners.append(listener) + + @callback + def async_allow_call(self, call_info: CallInfo) -> bool: + """Check if a call is allowed.""" + dev_reg = dr.async_get(self.hass) + ip_address = call_info.caller_ip + + user_agent = call_info.headers.get("user-agent", "") + user_agent_parts = user_agent.split() + if len(user_agent_parts) == 3 and user_agent_parts[0] == "Grandstream": + manuf = user_agent_parts[0] + model = user_agent_parts[1] + fw_version = user_agent_parts[2] + else: + manuf = None + model = user_agent if user_agent else None + fw_version = None + + device = dev_reg.async_get_device({(DOMAIN, ip_address)}) + + if device is None: + device = dev_reg.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={(DOMAIN, ip_address)}, + name=ip_address, + manufacturer=manuf, + model=model, + sw_version=fw_version, + ) + for listener in self._new_device_listeners: + listener(device) + return False + + if fw_version is not None and device.sw_version != fw_version: + dev_reg.async_update_device(device.id, sw_version=fw_version) + + ent_reg = er.async_get(self.hass) + + allowed_call_entity_id = ent_reg.async_get_entity_id( + "switch", DOMAIN, f"{ip_address}-allow_call" + ) + # If 2 requests come in fast, the device registry entry has been created + # but entity might not exist yet. + if allowed_call_entity_id is None: + return False + + if state := self.hass.states.get(allowed_call_entity_id): + return state.state == "on" + + return False diff --git a/homeassistant/components/voip/entity.py b/homeassistant/components/voip/entity.py new file mode 100644 index 00000000000..1114e933545 --- /dev/null +++ b/homeassistant/components/voip/entity.py @@ -0,0 +1,26 @@ +"""VoIP entities.""" + +from __future__ import annotations + +from homeassistant.const import EntityCategory +from homeassistant.helpers import device_registry as dr, entity + +from .const import DOMAIN + + +class VoIPEntity(entity.Entity): + """VoIP entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + _attr_entity_category = EntityCategory.CONFIG + + def __init__(self, device: dr.DeviceEntry) -> None: + """Initialize VoIP entity.""" + ip_address: str = next( + item[1] for item in device.identifiers if item[0] == DOMAIN + ) + self._attr_unique_id = f"{ip_address}-{self.entity_description.key}" + self._attr_device_info = entity.DeviceInfo( + identifiers={(DOMAIN, ip_address)}, + ) diff --git a/homeassistant/components/voip/strings.json b/homeassistant/components/voip/strings.json index da854032ca9..cd221790cc4 100644 --- a/homeassistant/components/voip/strings.json +++ b/homeassistant/components/voip/strings.json @@ -13,5 +13,12 @@ "error": { "invalid_ip_address": "Invalid IPv4 address." } + }, + "entity": { + "switch": { + "allow_call": { + "name": "Allow Calls" + } + } } } diff --git a/homeassistant/components/voip/switch.py b/homeassistant/components/voip/switch.py new file mode 100644 index 00000000000..0a9a91c0717 --- /dev/null +++ b/homeassistant/components/voip/switch.py @@ -0,0 +1,69 @@ +"""VoIP switch entities.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, restore_state +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import VoIPEntity + +if TYPE_CHECKING: + from . import DomainData + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up VoIP switch entities.""" + domain_data: DomainData = hass.data[DOMAIN] + + @callback + def async_add_device(device: dr.DeviceEntry) -> None: + """Add device.""" + async_add_entities([VoIPCallAllowedSwitch(device)]) + + domain_data.devices.async_add_new_device_listener(async_add_device) + + async_add_entities( + [ + VoIPCallAllowedSwitch(device) + for device in dr.async_entries_for_config_entry( + dr.async_get(hass), + config_entry.entry_id, + ) + ] + ) + + +class VoIPCallAllowedSwitch(VoIPEntity, restore_state.RestoreEntity, SwitchEntity): + """Entity to represent voip is allowed.""" + + entity_description = SwitchEntityDescription( + key="allow_call", translation_key="allow_call" + ) + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + self._attr_is_on = state is not None and state.state == STATE_ON + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on.""" + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off.""" + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 6d065461971..acc7dc537b6 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -1,7 +1,10 @@ """Voice over IP (VoIP) implementation.""" +from __future__ import annotations + import asyncio import logging import time +from typing import TYPE_CHECKING import async_timeout from voip_utils import CallInfo, RtpDatagramProtocol, SdpInfo, VoipDatagramProtocol @@ -17,13 +20,16 @@ from homeassistant.components.voice_assistant.vad import VoiceCommandSegmenter from homeassistant.const import __version__ from homeassistant.core import HomeAssistant +if TYPE_CHECKING: + from .devices import VoIPDevices + _LOGGER = logging.getLogger(__name__) class HassVoipDatagramProtocol(VoipDatagramProtocol): """HA UDP server for Voice over IP (VoIP).""" - def __init__(self, hass: HomeAssistant, allow_ips: set[str]) -> None: + def __init__(self, hass: HomeAssistant, devices: VoIPDevices) -> None: """Set up VoIP call handler.""" super().__init__( sdp_info=SdpInfo( @@ -37,11 +43,11 @@ class HassVoipDatagramProtocol(VoipDatagramProtocol): hass.config.language, ), ) - self.allow_ips = allow_ips + self.devices = devices def is_valid_call(self, call_info: CallInfo) -> bool: """Filter calls.""" - return call_info.caller_ip in self.allow_ips + return self.devices.async_allow_call(call_info) class PipelineRtpDatagramProtocol(RtpDatagramProtocol): @@ -64,7 +70,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): self.pipeline_timeout = pipeline_timeout self.audio_timeout = audio_timeout - self._audio_queue: "asyncio.Queue[bytes]" = asyncio.Queue() + self._audio_queue: asyncio.Queue[bytes] = asyncio.Queue() self._pipeline_task: asyncio.Task | None = None self._conversation_id: str | None = None diff --git a/tests/components/voip/conftest.py b/tests/components/voip/conftest.py new file mode 100644 index 00000000000..20aa5ac6a94 --- /dev/null +++ b/tests/components/voip/conftest.py @@ -0,0 +1,64 @@ +"""Test helpers for VoIP integration.""" + +from __future__ import annotations + +from unittest.mock import Mock, patch + +import pytest +from voip_utils import CallInfo + +from homeassistant.components.voip import DOMAIN, VoIPDevices +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.fixture +def config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create a config entry.""" + entry = MockConfigEntry(domain=DOMAIN, data={}) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +async def setup_voip(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up VoIP integration.""" + with patch("homeassistant.components.voip._create_sip_server", return_value=Mock()): + assert await async_setup_component(hass, DOMAIN, {}) + assert config_entry.state == ConfigEntryState.LOADED + yield + + +@pytest.fixture +async def voip_devices(hass: HomeAssistant, setup_voip: None) -> VoIPDevices: + """Get VoIP devices object from a configured instance.""" + return hass.data[DOMAIN].devices + + +@pytest.fixture +def call_info() -> CallInfo: + """Fake call info.""" + return CallInfo( + caller_ip="192.168.1.210", + caller_sip_port=5060, + caller_rtp_port=5004, + server_ip="192.168.1.10", + headers={ + "via": "SIP/2.0/UDP 192.168.1.210:5060;branch=z9hG4bK912387041;rport", + "from": ";tag=1836983217", + "to": "", + "call-id": "860888843-5060-9@BJC.BGI.B.CBA", + "cseq": "80 INVITE", + "contact": "", + "max-forwards": "70", + "user-agent": "Grandstream HT801 1.0.17.5", + "supported": "replaces, path, timer, eventlist", + "allow": "INVITE, ACK, OPTIONS, CANCEL, BYE, SUBSCRIBE, NOTIFY, INFO, REFER, UPDATE", + "content-type": "application/sdp", + "accept": "application/sdp, application/dtmf-relay", + "content-length": "480", + }, + ) diff --git a/tests/components/voip/test_config_flow.py b/tests/components/voip/test_config_flow.py index 1f14ab94b00..9b3420775c2 100644 --- a/tests/components/voip/test_config_flow.py +++ b/tests/components/voip/test_config_flow.py @@ -3,12 +3,9 @@ from unittest.mock import patch from homeassistant import config_entries from homeassistant.components import voip -from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry - async def test_form_user(hass: HomeAssistant) -> None: """Test user form config flow.""" @@ -25,59 +22,21 @@ async def test_form_user(hass: HomeAssistant) -> None: ) as mock_setup_entry: result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_IP_ADDRESS: "127.0.0.1"}, + {}, ) await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == {CONF_IP_ADDRESS: "127.0.0.1"} + assert result["data"] == {} assert len(mock_setup_entry.mock_calls) == 1 -async def test_invalid_ip(hass: HomeAssistant) -> None: - """Test user form config flow with invalid ip address.""" - +async def test_single_instance( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> None: + """Test that only one instance can be created.""" result = await hass.config_entries.flow.async_init( voip.DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" - assert not result["errors"] - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_IP_ADDRESS: "not an ip address"}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {CONF_IP_ADDRESS: "invalid_ip_address"} - - -async def test_load_unload_entry( - hass: HomeAssistant, - socket_enabled, - unused_udp_port_factory, -) -> None: - """Test adding/removing VoIP.""" - entry = MockConfigEntry( - domain=voip.DOMAIN, - data={ - CONF_IP_ADDRESS: "127.0.0.1", - }, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.voip.SIP_PORT", - new=unused_udp_port_factory(), - ): - assert await voip.async_setup_entry(hass, entry) - - # Verify single instance - result = await hass.config_entries.flow.async_init( - voip.DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == "abort" - assert result["reason"] == "single_instance_allowed" - - assert await voip.async_unload_entry(hass, entry) + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/voip/test_devices.py b/tests/components/voip/test_devices.py new file mode 100644 index 00000000000..9918144e527 --- /dev/null +++ b/tests/components/voip/test_devices.py @@ -0,0 +1,50 @@ +"""Test VoIP devices.""" + +from __future__ import annotations + +from voip_utils import CallInfo + +from homeassistant.components.voip import DOMAIN, VoIPDevices +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry + + +async def test_device_registry_info( + hass: HomeAssistant, + voip_devices: VoIPDevices, + call_info: CallInfo, + device_registry: DeviceRegistry, +) -> None: + """Test info in device registry.""" + assert not voip_devices.async_allow_call(call_info) + + device = device_registry.async_get_device({(DOMAIN, call_info.caller_ip)}) + assert device is not None + assert device.name == call_info.caller_ip + 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" + + assert not voip_devices.async_allow_call(call_info) + + device = device_registry.async_get_device({(DOMAIN, call_info.caller_ip)}) + assert device.sw_version == "2.0.0.0" + + +async def test_device_registry_info_from_unknown_phone( + hass: HomeAssistant, + voip_devices: VoIPDevices, + call_info: CallInfo, + device_registry: DeviceRegistry, +) -> None: + """Test info in device registry from unknown phone.""" + call_info.headers["user-agent"] = "Unknown" + assert not voip_devices.async_allow_call(call_info) + + device = device_registry.async_get_device({(DOMAIN, call_info.caller_ip)}) + assert device.manufacturer is None + assert device.model == "Unknown" + assert device.sw_version is None diff --git a/tests/components/voip/test_init.py b/tests/components/voip/test_init.py new file mode 100644 index 00000000000..77c14260f8e --- /dev/null +++ b/tests/components/voip/test_init.py @@ -0,0 +1,14 @@ +"""Test VoIP init.""" +from homeassistant.core import HomeAssistant + +# socket_enabled, +# unused_udp_port_factory, + + +async def test_unload_entry( + hass: HomeAssistant, + config_entry, + setup_voip, +) -> None: + """Test adding/removing VoIP.""" + assert await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/components/voip/test_switch.py b/tests/components/voip/test_switch.py new file mode 100644 index 00000000000..5363fb8c2d6 --- /dev/null +++ b/tests/components/voip/test_switch.py @@ -0,0 +1,40 @@ +"""Test VoIP switch devices.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant + + +async def test_allow_call( + hass: HomeAssistant, config_entry, voip_devices, call_info +) -> None: + """Test allow call.""" + assert not voip_devices.async_allow_call(call_info) + await hass.async_block_till_done() + + state = hass.states.get("switch.192_168_1_210_allow_calls") + assert state is not None + assert state.state == "off" + + await hass.config_entries.async_reload(config_entry.entry_id) + + state = hass.states.get("switch.192_168_1_210_allow_calls") + assert state.state == "off" + + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": "switch.192_168_1_210_allow_calls"}, + blocking=True, + ) + + assert voip_devices.async_allow_call(call_info) + + state = hass.states.get("switch.192_168_1_210_allow_calls") + assert state.state == "on" + + await hass.config_entries.async_reload(config_entry.entry_id) + + state = hass.states.get("switch.192_168_1_210_allow_calls") + assert state is not None + assert state.state == "on"