VoIP: Add is active call binary sensor (#91486)

* Refactor VoIP integration for more entities

* Add active call binary sensor

* Add actually missing binary sensor files

* Improve test coverage
This commit is contained in:
Paulus Schoutsen 2023-04-16 22:59:05 -04:00 committed by GitHub
parent 58ea657fbc
commit 2b6fd0df6a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 305 additions and 84 deletions

View File

@ -17,7 +17,10 @@ from .const import DOMAIN
from .devices import VoIPDevices
from .voip import HassVoipDatagramProtocol
PLATFORMS = (Platform.SWITCH,)
PLATFORMS = (
Platform.BINARY_SENSOR,
Platform.SWITCH,
)
_LOGGER = logging.getLogger(__name__)
_IP_WILDCARD = "0.0.0.0"
@ -25,6 +28,7 @@ __all__ = [
"DOMAIN",
"async_setup_entry",
"async_unload_entry",
"async_remove_config_entry_device",
]
@ -39,6 +43,7 @@ class DomainData:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up VoIP integration from a config entry."""
devices = VoIPDevices(hass, entry)
devices.async_setup()
transport = await _create_sip_server(
hass,
lambda: HassVoipDatagramProtocol(hass, devices),
@ -79,5 +84,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove config entry from a device."""
"""Remove device from a config entry."""
return True

View File

@ -0,0 +1,60 @@
"""Binary sensor for VoIP."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .devices import VoIPDevice
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 binary sensor entities."""
domain_data: DomainData = hass.data[DOMAIN]
@callback
def async_add_device(device: VoIPDevice) -> None:
"""Add device."""
async_add_entities([VoIPCallActive(device)])
domain_data.devices.async_add_new_device_listener(async_add_device)
async_add_entities([VoIPCallActive(device) for device in domain_data.devices])
class VoIPCallActive(VoIPEntity, BinarySensorEntity):
"""Entity to represent voip is allowed."""
entity_description = BinarySensorEntityDescription(
key="call_active",
translation_key="call_active",
)
_attr_is_on = False
async def async_added_to_hass(self) -> None:
"""Call when entity about to be added to hass."""
await super().async_added_to_hass()
self.async_on_remove(self._device.async_listen_update(self._is_active_changed))
@callback
def _is_active_changed(self, device: VoIPDevice) -> None:
"""Call when active state changed."""
self._attr_is_on = self._device.is_active
self.async_write_ha_state()

View File

@ -1,17 +1,61 @@
"""Class to manage devices."""
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Iterator
from dataclasses import dataclass, field
from voip_utils import CallInfo
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import DOMAIN
@dataclass
class VoIPDevice:
"""Class to store device."""
voip_id: str
device_id: str
is_active: bool = False
update_listeners: list[Callable[[VoIPDevice], None]] = field(default_factory=list)
@callback
def set_is_active(self, active: bool) -> None:
"""Set active state."""
self.is_active = active
for listener in self.update_listeners:
listener(self)
@callback
def async_listen_update(
self, listener: Callable[[VoIPDevice], None]
) -> Callable[[], None]:
"""Listen for updates."""
self.update_listeners.append(listener)
return lambda: self.update_listeners.remove(listener)
@callback
def async_allow_call(self, hass: HomeAssistant) -> bool:
"""Return if call is allowed."""
ent_reg = er.async_get(hass)
allowed_call_entity_id = ent_reg.async_get_entity_id(
"switch", DOMAIN, f"{self.voip_id}-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 := hass.states.get(allowed_call_entity_id):
return state.state == "on"
return False
class VoIPDevices:
"""Class to store devices."""
@ -19,21 +63,53 @@ class VoIPDevices:
"""Initialize VoIP devices."""
self.hass = hass
self.config_entry = config_entry
self._new_device_listeners: list[Callable[[dr.DeviceEntry], None]] = []
self._new_device_listeners: list[Callable[[VoIPDevice], None]] = []
self.devices: dict[str, VoIPDevice] = {}
@callback
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
):
voip_id = next(
(item[1] for item in device.identifiers if item[0] == DOMAIN), None
)
if voip_id is None:
continue
self.devices[voip_id] = VoIPDevice(
voip_id=voip_id,
device_id=device.id,
)
@callback
def async_device_removed(ev: Event) -> None:
"""Handle device removed."""
removed_id = ev.data["device_id"]
self.devices = {
voip_id: voip_device
for voip_id, voip_device in self.devices.items()
if voip_device.device_id != removed_id
}
self.config_entry.async_on_unload(
self.hass.bus.async_listen(
dr.EVENT_DEVICE_REGISTRY_UPDATED,
async_device_removed,
callback(lambda ev: ev.data.get("action") == "remove"),
)
)
@callback
def async_add_new_device_listener(
self, listener: Callable[[dr.DeviceEntry], None]
self, listener: Callable[[VoIPDevice], 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
def async_get_or_create(self, call_info: CallInfo) -> VoIPDevice:
"""Get or create a device."""
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":
@ -45,35 +121,34 @@ class VoIPDevices:
model = user_agent if user_agent else None
fw_version = None
device = dev_reg.async_get_device({(DOMAIN, ip_address)})
dev_reg = dr.async_get(self.hass)
voip_id = call_info.caller_ip
voip_device = self.devices.get(voip_id)
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 voip_device is not None:
device = dev_reg.async_get(voip_device.device_id)
if device and fw_version and device.sw_version != fw_version:
dev_reg.async_update_device(device.id, sw_version=fw_version)
if fw_version is not None and device.sw_version != fw_version:
dev_reg.async_update_device(device.id, sw_version=fw_version)
return voip_device
ent_reg = er.async_get(self.hass)
allowed_call_entity_id = ent_reg.async_get_entity_id(
"switch", DOMAIN, f"{ip_address}-allow_call"
device = dev_reg.async_get_or_create(
config_entry_id=self.config_entry.entry_id,
identifiers={(DOMAIN, voip_id)},
name=voip_id,
manufacturer=manuf,
model=model,
sw_version=fw_version,
)
# 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
voip_device = self.devices[voip_id] = VoIPDevice(
voip_id=voip_id,
device_id=device.id,
)
for listener in self._new_device_listeners:
listener(voip_device)
if state := self.hass.states.get(allowed_call_entity_id):
return state.state == "on"
return voip_device
return False
def __iter__(self) -> Iterator[VoIPDevice]:
"""Iterate over devices."""
return iter(self.devices.values())

View File

@ -2,10 +2,10 @@
from __future__ import annotations
from homeassistant.const import EntityCategory
from homeassistant.helpers import device_registry as dr, entity
from homeassistant.helpers import entity
from .const import DOMAIN
from .devices import VoIPDevice
class VoIPEntity(entity.Entity):
@ -13,14 +13,11 @@ class VoIPEntity(entity.Entity):
_attr_has_entity_name = True
_attr_should_poll = False
_attr_entity_category = EntityCategory.CONFIG
def __init__(self, device: dr.DeviceEntry) -> None:
def __init__(self, device: VoIPDevice) -> 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._device = device
self._attr_unique_id = f"{device.voip_id}-{self.entity_description.key}"
self._attr_device_info = entity.DeviceInfo(
identifiers={(DOMAIN, ip_address)},
identifiers={(DOMAIN, device.voip_id)},
)

View File

@ -15,6 +15,11 @@
}
},
"entity": {
"binary_sensor": {
"call_active": {
"name": "Call Active"
}
},
"switch": {
"allow_call": {
"name": "Allow Calls"

View File

@ -6,12 +6,13 @@ 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.const import STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, restore_state
from homeassistant.helpers import restore_state
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .devices import VoIPDevice
from .entity import VoIPEntity
if TYPE_CHECKING:
@ -27,20 +28,14 @@ async def async_setup_entry(
domain_data: DomainData = hass.data[DOMAIN]
@callback
def async_add_device(device: dr.DeviceEntry) -> None:
def async_add_device(device: VoIPDevice) -> 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,
)
]
[VoIPCallAllowedSwitch(device) for device in domain_data.devices]
)
@ -48,7 +43,9 @@ class VoIPCallAllowedSwitch(VoIPEntity, restore_state.RestoreEntity, SwitchEntit
"""Entity to represent voip is allowed."""
entity_description = SwitchEntityDescription(
key="allow_call", translation_key="allow_call"
key="allow_call",
translation_key="allow_call",
entity_category=EntityCategory.CONFIG,
)
async def async_added_to_hass(self) -> None:

View File

@ -21,7 +21,7 @@ from homeassistant.const import __version__
from homeassistant.core import HomeAssistant
if TYPE_CHECKING:
from .devices import VoIPDevices
from .devices import VoIPDevice, VoIPDevices
_LOGGER = logging.getLogger(__name__)
@ -41,13 +41,16 @@ class HassVoipDatagramProtocol(VoipDatagramProtocol):
protocol_factory=lambda call_info: PipelineRtpDatagramProtocol(
hass,
hass.config.language,
devices.async_get_or_create(call_info),
),
)
self.hass = hass
self.devices = devices
def is_valid_call(self, call_info: CallInfo) -> bool:
"""Filter calls."""
return self.devices.async_allow_call(call_info)
device = self.devices.async_get_or_create(call_info)
return device.async_allow_call(self.hass)
class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
@ -57,6 +60,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
self,
hass: HomeAssistant,
language: str,
voip_device: VoIPDevice,
pipeline_timeout: float = 30.0,
audio_timeout: float = 2.0,
) -> None:
@ -66,6 +70,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
self.hass = hass
self.language = language
self.voip_device = voip_device
self.pipeline: Pipeline | None = None
self.pipeline_timeout = pipeline_timeout
self.audio_timeout = audio_timeout
@ -76,7 +81,13 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
def connection_made(self, transport):
"""Server is ready."""
self.transport = transport
super().connection_made(transport)
self.voip_device.set_is_active(True)
def connection_lost(self, exc):
"""Handle connection is lost or closed."""
super().connection_lost(exc)
self.voip_device.set_is_active(False)
def on_chunk(self, audio_bytes: bytes) -> None:
"""Handle raw audio chunk."""

View File

@ -1,7 +1,7 @@
{
"issues": {
"trigger_missing_local_only": {
"description": "A choice needs to be made about whether the {webhook_id} webhook automation trigger is accessible from the internet. Edit the {automation_name} automation, and click the gear icon beside the Webhook ID to choose a value for 'Only accessible from the local network'",
"description": "A choice needs to be made about whether the {webhook_id} webhook automation trigger is accessible from the internet. [Edit the automation]({edit}) \"{automation_name}\", (`{entity_id}`) and click the gear icon beside the Webhook ID to choose a value for 'Only accessible from the local network'",
"title": "Update webhook trigger: {webhook_id}"
}
}

View File

@ -7,7 +7,8 @@ from unittest.mock import Mock, patch
import pytest
from voip_utils import CallInfo
from homeassistant.components.voip import DOMAIN, VoIPDevices
from homeassistant.components.voip import DOMAIN
from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@ -62,3 +63,14 @@ def call_info() -> CallInfo:
"content-length": "480",
},
)
@pytest.fixture
async def voip_device(
hass: HomeAssistant, voip_devices: VoIPDevices, call_info: CallInfo
) -> VoIPDevice:
"""Get a VoIP device fixture."""
device = voip_devices.async_get_or_create(call_info)
# to make sure all platforms are set up
await hass.async_block_till_done()
return device

View File

@ -0,0 +1,25 @@
"""Test VoIP binary sensor devices."""
from homeassistant.components.voip.devices import VoIPDevice
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
async def test_allow_call(
hass: HomeAssistant,
config_entry: ConfigEntry,
voip_device: VoIPDevice,
) -> None:
"""Test allow call."""
state = hass.states.get("binary_sensor.192_168_1_210_call_active")
assert state is not None
assert state.state == "off"
voip_device.set_is_active(True)
state = hass.states.get("binary_sensor.192_168_1_210_call_active")
assert state.state == "on"
voip_device.set_is_active(False)
state = hass.states.get("binary_sensor.192_168_1_210_call_active")
assert state.state == "off"

View File

@ -4,7 +4,8 @@ from __future__ import annotations
from voip_utils import CallInfo
from homeassistant.components.voip import DOMAIN, VoIPDevices
from homeassistant.components.voip import DOMAIN
from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceRegistry
@ -16,7 +17,8 @@ async def test_device_registry_info(
device_registry: DeviceRegistry,
) -> None:
"""Test info in device registry."""
assert not voip_devices.async_allow_call(call_info)
voip_device = voip_devices.async_get_or_create(call_info)
assert not voip_device.async_allow_call(hass)
device = device_registry.async_get_device({(DOMAIN, call_info.caller_ip)})
assert device is not None
@ -27,8 +29,9 @@ async def test_device_registry_info(
# Test we update the device if the fw updates
call_info.headers["user-agent"] = "Grandstream HT801 2.0.0.0"
voip_device = voip_devices.async_get_or_create(call_info)
assert not voip_devices.async_allow_call(call_info)
assert not voip_device.async_allow_call(hass)
device = device_registry.async_get_device({(DOMAIN, call_info.caller_ip)})
assert device.sw_version == "2.0.0.0"
@ -42,9 +45,28 @@ async def test_device_registry_info_from_unknown_phone(
) -> None:
"""Test info in device registry from unknown phone."""
call_info.headers["user-agent"] = "Unknown"
assert not voip_devices.async_allow_call(call_info)
voip_device = voip_devices.async_get_or_create(call_info)
assert not voip_device.async_allow_call(hass)
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
async def test_remove_device_registry_entry(
hass: HomeAssistant,
voip_device: VoIPDevice,
voip_devices: VoIPDevices,
device_registry: DeviceRegistry,
) -> None:
"""Test removing a device registry entry."""
assert voip_device.voip_id in voip_devices.devices
assert hass.states.get("switch.192_168_1_210_allow_calls") is not None
device_registry.async_remove_device(voip_device.device_id)
await hass.async_block_till_done()
await hass.async_block_till_done()
assert hass.states.get("switch.192_168_1_210_allow_calls") is None
assert voip_device.voip_id not in voip_devices.devices

View File

@ -1,9 +1,6 @@
"""Test VoIP init."""
from homeassistant.core import HomeAssistant
# socket_enabled,
# unused_udp_port_factory,
async def test_unload_entry(
hass: HomeAssistant,

View File

@ -1,16 +1,16 @@
"""Test VoIP switch devices."""
from __future__ import annotations
from homeassistant.components.voip.devices import VoIPDevice
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
async def test_allow_call(
hass: HomeAssistant, config_entry, voip_devices, call_info
hass: HomeAssistant,
config_entry: ConfigEntry,
voip_device: VoIPDevice,
) -> None:
"""Test allow call."""
assert not voip_devices.async_allow_call(call_info)
await hass.async_block_till_done()
assert not voip_device.async_allow_call(hass)
state = hass.states.get("switch.192_168_1_210_allow_calls")
assert state is not None
@ -28,13 +28,25 @@ async def test_allow_call(
blocking=True,
)
assert voip_devices.async_allow_call(call_info)
assert voip_device.async_allow_call(hass)
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)
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 == "on"
await hass.services.async_call(
"switch",
"turn_off",
{"entity_id": "switch.192_168_1_210_allow_calls"},
blocking=True,
)
assert not voip_device.async_allow_call(hass)
state = hass.states.get("switch.192_168_1_210_allow_calls")
assert state.state == "off"

View File

@ -5,6 +5,7 @@ from unittest.mock import Mock, patch
import async_timeout
from homeassistant.components import assist_pipeline, voip
from homeassistant.components.voip.devices import VoIPDevice
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@ -12,7 +13,10 @@ _ONE_SECOND = 16000 * 2 # 16Khz 16-bit
_MEDIA_ID = "12345"
async def test_pipeline(hass: HomeAssistant) -> None:
async def test_pipeline(
hass: HomeAssistant,
voip_device: VoIPDevice,
) -> None:
"""Test that pipeline function is called from RTP protocol."""
assert await async_setup_component(hass, "voip", {})
@ -80,8 +84,7 @@ async def test_pipeline(hass: HomeAssistant) -> None:
new=async_get_media_source_audio,
):
rtp_protocol = voip.voip.PipelineRtpDatagramProtocol(
hass,
hass.config.language,
hass, hass.config.language, voip_device
)
rtp_protocol.transport = Mock()
@ -108,7 +111,7 @@ async def test_pipeline(hass: HomeAssistant) -> None:
await done.wait()
async def test_pipeline_timeout(hass: HomeAssistant) -> None:
async def test_pipeline_timeout(hass: HomeAssistant, voip_device: VoIPDevice) -> None:
"""Test timeout during pipeline run."""
assert await async_setup_component(hass, "voip", {})
@ -122,7 +125,7 @@ async def test_pipeline_timeout(hass: HomeAssistant) -> None:
new=async_pipeline_from_audio_stream,
):
rtp_protocol = voip.voip.PipelineRtpDatagramProtocol(
hass, hass.config.language, pipeline_timeout=0.001
hass, hass.config.language, voip_device, pipeline_timeout=0.001
)
transport = Mock(spec=["close"])
rtp_protocol.connection_made(transport)
@ -138,7 +141,7 @@ async def test_pipeline_timeout(hass: HomeAssistant) -> None:
await done.wait()
async def test_stt_stream_timeout(hass: HomeAssistant) -> None:
async def test_stt_stream_timeout(hass: HomeAssistant, voip_device: VoIPDevice) -> None:
"""Test timeout in STT stream during pipeline run."""
assert await async_setup_component(hass, "voip", {})
@ -155,7 +158,7 @@ async def test_stt_stream_timeout(hass: HomeAssistant) -> None:
new=async_pipeline_from_audio_stream,
):
rtp_protocol = voip.voip.PipelineRtpDatagramProtocol(
hass, hass.config.language, audio_timeout=0.001
hass, hass.config.language, voip_device, audio_timeout=0.001
)
transport = Mock(spec=["close"])
rtp_protocol.connection_made(transport)