mirror of https://github.com/home-assistant/core
240 lines
7.8 KiB
Python
240 lines
7.8 KiB
Python
"""UniFi Protect Integration services."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import functools
|
|
from typing import Any, cast
|
|
|
|
from pydantic import ValidationError
|
|
from uiprotect.api import ProtectApiClient
|
|
from uiprotect.data import Camera, Chime
|
|
from uiprotect.exceptions import ClientError
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
|
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME, Platform
|
|
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
|
from homeassistant.helpers import (
|
|
config_validation as cv,
|
|
device_registry as dr,
|
|
entity_registry as er,
|
|
)
|
|
from homeassistant.helpers.service import async_extract_referenced_entity_ids
|
|
from homeassistant.util.read_only_dict import ReadOnlyDict
|
|
|
|
from .const import ATTR_MESSAGE, DOMAIN
|
|
from .data import async_ufp_instance_for_config_entry_ids
|
|
|
|
SERVICE_ADD_DOORBELL_TEXT = "add_doorbell_text"
|
|
SERVICE_REMOVE_DOORBELL_TEXT = "remove_doorbell_text"
|
|
SERVICE_SET_PRIVACY_ZONE = "set_privacy_zone"
|
|
SERVICE_REMOVE_PRIVACY_ZONE = "remove_privacy_zone"
|
|
SERVICE_SET_CHIME_PAIRED = "set_chime_paired_doorbells"
|
|
|
|
ALL_GLOBAL_SERIVCES = [
|
|
SERVICE_ADD_DOORBELL_TEXT,
|
|
SERVICE_REMOVE_DOORBELL_TEXT,
|
|
SERVICE_SET_CHIME_PAIRED,
|
|
SERVICE_REMOVE_PRIVACY_ZONE,
|
|
]
|
|
|
|
DOORBELL_TEXT_SCHEMA = vol.All(
|
|
vol.Schema(
|
|
{
|
|
**cv.ENTITY_SERVICE_FIELDS,
|
|
vol.Required(ATTR_MESSAGE): cv.string,
|
|
},
|
|
),
|
|
cv.has_at_least_one_key(ATTR_DEVICE_ID),
|
|
)
|
|
|
|
CHIME_PAIRED_SCHEMA = vol.All(
|
|
vol.Schema(
|
|
{
|
|
**cv.ENTITY_SERVICE_FIELDS,
|
|
"doorbells": cv.TARGET_SERVICE_FIELDS,
|
|
},
|
|
),
|
|
cv.has_at_least_one_key(ATTR_DEVICE_ID),
|
|
)
|
|
|
|
REMOVE_PRIVACY_ZONE_SCHEMA = vol.All(
|
|
vol.Schema(
|
|
{
|
|
**cv.ENTITY_SERVICE_FIELDS,
|
|
vol.Required(ATTR_NAME): cv.string,
|
|
},
|
|
),
|
|
cv.has_at_least_one_key(ATTR_DEVICE_ID),
|
|
)
|
|
|
|
|
|
@callback
|
|
def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiClient:
|
|
device_registry = dr.async_get(hass)
|
|
if not (device_entry := device_registry.async_get(device_id)):
|
|
raise HomeAssistantError(f"No device found for device id: {device_id}")
|
|
|
|
if device_entry.via_device_id is not None:
|
|
return _async_get_ufp_instance(hass, device_entry.via_device_id)
|
|
|
|
config_entry_ids = device_entry.config_entries
|
|
if ufp_instance := async_ufp_instance_for_config_entry_ids(hass, config_entry_ids):
|
|
return ufp_instance
|
|
|
|
raise HomeAssistantError(f"No device found for device id: {device_id}")
|
|
|
|
|
|
@callback
|
|
def _async_get_ufp_camera(hass: HomeAssistant, call: ServiceCall) -> Camera:
|
|
ref = async_extract_referenced_entity_ids(hass, call)
|
|
entity_registry = er.async_get(hass)
|
|
|
|
entity_id = ref.indirectly_referenced.pop()
|
|
camera_entity = entity_registry.async_get(entity_id)
|
|
assert camera_entity is not None
|
|
assert camera_entity.device_id is not None
|
|
camera_mac = _async_unique_id_to_mac(camera_entity.unique_id)
|
|
|
|
instance = _async_get_ufp_instance(hass, camera_entity.device_id)
|
|
return cast(Camera, instance.bootstrap.get_device_from_mac(camera_mac))
|
|
|
|
|
|
@callback
|
|
def _async_get_protect_from_call(
|
|
hass: HomeAssistant, call: ServiceCall
|
|
) -> set[ProtectApiClient]:
|
|
return {
|
|
_async_get_ufp_instance(hass, device_id)
|
|
for device_id in async_extract_referenced_entity_ids(
|
|
hass, call
|
|
).referenced_devices
|
|
}
|
|
|
|
|
|
async def _async_service_call_nvr(
|
|
hass: HomeAssistant,
|
|
call: ServiceCall,
|
|
method: str,
|
|
*args: Any,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
instances = _async_get_protect_from_call(hass, call)
|
|
try:
|
|
await asyncio.gather(
|
|
*(getattr(i.bootstrap.nvr, method)(*args, **kwargs) for i in instances)
|
|
)
|
|
except (ClientError, ValidationError) as err:
|
|
raise HomeAssistantError(str(err)) from err
|
|
|
|
|
|
async def add_doorbell_text(hass: HomeAssistant, call: ServiceCall) -> None:
|
|
"""Add a custom doorbell text message."""
|
|
message: str = call.data[ATTR_MESSAGE]
|
|
await _async_service_call_nvr(hass, call, "add_custom_doorbell_message", message)
|
|
|
|
|
|
async def remove_doorbell_text(hass: HomeAssistant, call: ServiceCall) -> None:
|
|
"""Remove a custom doorbell text message."""
|
|
message: str = call.data[ATTR_MESSAGE]
|
|
await _async_service_call_nvr(hass, call, "remove_custom_doorbell_message", message)
|
|
|
|
|
|
async def remove_privacy_zone(hass: HomeAssistant, call: ServiceCall) -> None:
|
|
"""Remove privacy zone from camera."""
|
|
|
|
name: str = call.data[ATTR_NAME]
|
|
camera = _async_get_ufp_camera(hass, call)
|
|
|
|
remove_index: int | None = None
|
|
for index, zone in enumerate(camera.privacy_zones):
|
|
if zone.name == name:
|
|
remove_index = index
|
|
break
|
|
|
|
if remove_index is None:
|
|
raise ServiceValidationError(
|
|
f"Could not find privacy zone with name {name} on camera {camera.display_name}."
|
|
)
|
|
|
|
def remove_zone() -> None:
|
|
camera.privacy_zones.pop(remove_index)
|
|
|
|
await camera.queue_update(remove_zone)
|
|
|
|
|
|
@callback
|
|
def _async_unique_id_to_mac(unique_id: str) -> str:
|
|
"""Extract the MAC address from the registry entry unique id."""
|
|
return unique_id.split("_")[0]
|
|
|
|
|
|
async def set_chime_paired_doorbells(hass: HomeAssistant, call: ServiceCall) -> None:
|
|
"""Set paired doorbells on chime."""
|
|
ref = async_extract_referenced_entity_ids(hass, call)
|
|
entity_registry = er.async_get(hass)
|
|
|
|
entity_id = ref.indirectly_referenced.pop()
|
|
chime_button = entity_registry.async_get(entity_id)
|
|
assert chime_button is not None
|
|
assert chime_button.device_id is not None
|
|
chime_mac = _async_unique_id_to_mac(chime_button.unique_id)
|
|
|
|
instance = _async_get_ufp_instance(hass, chime_button.device_id)
|
|
chime = instance.bootstrap.get_device_from_mac(chime_mac)
|
|
chime = cast(Chime, chime)
|
|
assert chime is not None
|
|
|
|
call.data = ReadOnlyDict(call.data.get("doorbells") or {})
|
|
doorbell_refs = async_extract_referenced_entity_ids(hass, call)
|
|
doorbell_ids: set[str] = set()
|
|
for camera_id in doorbell_refs.referenced | doorbell_refs.indirectly_referenced:
|
|
doorbell_sensor = entity_registry.async_get(camera_id)
|
|
assert doorbell_sensor is not None
|
|
if (
|
|
doorbell_sensor.platform != DOMAIN
|
|
or doorbell_sensor.domain != Platform.BINARY_SENSOR
|
|
or doorbell_sensor.original_device_class
|
|
!= BinarySensorDeviceClass.OCCUPANCY
|
|
):
|
|
continue
|
|
doorbell_mac = _async_unique_id_to_mac(doorbell_sensor.unique_id)
|
|
camera = instance.bootstrap.get_device_from_mac(doorbell_mac)
|
|
assert camera is not None
|
|
doorbell_ids.add(camera.id)
|
|
data_before_changed = chime.dict_with_excludes()
|
|
chime.camera_ids = sorted(doorbell_ids)
|
|
await chime.save_device(data_before_changed)
|
|
|
|
|
|
def async_setup_services(hass: HomeAssistant) -> None:
|
|
"""Set up the global UniFi Protect services."""
|
|
services = [
|
|
(
|
|
SERVICE_ADD_DOORBELL_TEXT,
|
|
functools.partial(add_doorbell_text, hass),
|
|
DOORBELL_TEXT_SCHEMA,
|
|
),
|
|
(
|
|
SERVICE_REMOVE_DOORBELL_TEXT,
|
|
functools.partial(remove_doorbell_text, hass),
|
|
DOORBELL_TEXT_SCHEMA,
|
|
),
|
|
(
|
|
SERVICE_SET_CHIME_PAIRED,
|
|
functools.partial(set_chime_paired_doorbells, hass),
|
|
CHIME_PAIRED_SCHEMA,
|
|
),
|
|
(
|
|
SERVICE_REMOVE_PRIVACY_ZONE,
|
|
functools.partial(remove_privacy_zone, hass),
|
|
REMOVE_PRIVACY_ZONE_SCHEMA,
|
|
),
|
|
]
|
|
for name, method, schema in services:
|
|
if hass.services.has_service(DOMAIN, name):
|
|
continue
|
|
hass.services.async_register(DOMAIN, name, method, schema=schema)
|