Skip to content

Commit

Permalink
VoIP: Add is active call binary sensor (home-assistant#91486)
Browse files Browse the repository at this point in the history
* Refactor VoIP integration for more entities

* Add active call binary sensor

* Add actually missing binary sensor files

* Improve test coverage
  • Loading branch information
balloob authored Apr 17, 2023
1 parent 58ea657 commit 2b6fd0d
Show file tree
Hide file tree
Showing 14 changed files with 308 additions and 87 deletions.
9 changes: 7 additions & 2 deletions homeassistant/components/voip/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,18 @@
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"

__all__ = [
"DOMAIN",
"async_setup_entry",
"async_unload_entry",
"async_remove_config_entry_device",
]


Expand All @@ -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),
Expand Down Expand Up @@ -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
60 changes: 60 additions & 0 deletions homeassistant/components/voip/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -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()
151 changes: 113 additions & 38 deletions homeassistant/components/voip/devices.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,115 @@
"""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."""

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]] = []
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":
Expand All @@ -45,35 +121,34 @@ def async_allow_call(self, call_info: CallInfo) -> bool:
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"
dev_reg = dr.async_get(self.hass)
voip_id = call_info.caller_ip
voip_device = self.devices.get(voip_id)

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)

return voip_device

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())
15 changes: 6 additions & 9 deletions homeassistant/components/voip/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,22 @@

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):
"""VoIP 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)},
)
5 changes: 5 additions & 0 deletions homeassistant/components/voip/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
}
},
"entity": {
"binary_sensor": {
"call_active": {
"name": "Call Active"
}
},
"switch": {
"allow_call": {
"name": "Allow Calls"
Expand Down
19 changes: 8 additions & 11 deletions homeassistant/components/voip/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@

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:
Expand All @@ -27,28 +28,24 @@ 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]
)


class VoIPCallAllowedSwitch(VoIPEntity, restore_state.RestoreEntity, SwitchEntity):
"""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:
Expand Down
Loading

0 comments on commit 2b6fd0d

Please sign in to comment.