Adds UP Chime support for UniFi Protect (#71874)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Christopher Bailey 2022-05-20 16:16:01 -04:00 committed by GitHub
parent ad5dbae425
commit 267266c7c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 398 additions and 81 deletions

View File

@ -43,6 +43,22 @@ ALL_DEVICE_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = (
), ),
) )
CHIME_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = (
ProtectButtonEntityDescription(
key="play",
name="Play Chime",
device_class=DEVICE_CLASS_CHIME_BUTTON,
icon="mdi:play",
ufp_press="play",
),
ProtectButtonEntityDescription(
key="play_buzzer",
name="Play Buzzer",
icon="mdi:play",
ufp_press="play_buzzer",
),
)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -53,7 +69,7 @@ async def async_setup_entry(
data: ProtectData = hass.data[DOMAIN][entry.entry_id] data: ProtectData = hass.data[DOMAIN][entry.entry_id]
entities: list[ProtectDeviceEntity] = async_all_device_entities( entities: list[ProtectDeviceEntity] = async_all_device_entities(
data, ProtectButton, all_descs=ALL_DEVICE_BUTTONS data, ProtectButton, all_descs=ALL_DEVICE_BUTTONS, chime_descs=CHIME_BUTTONS
) )
async_add_entities(entities) async_add_entities(entities)

View File

@ -38,6 +38,7 @@ DEVICES_THAT_ADOPT = {
ModelType.VIEWPORT, ModelType.VIEWPORT,
ModelType.SENSOR, ModelType.SENSOR,
ModelType.DOORLOCK, ModelType.DOORLOCK,
ModelType.CHIME,
} }
DEVICES_WITH_ENTITIES = DEVICES_THAT_ADOPT | {ModelType.NVR} DEVICES_WITH_ENTITIES = DEVICES_THAT_ADOPT | {ModelType.NVR}
DEVICES_FOR_SUBSCRIBE = DEVICES_WITH_ENTITIES | {ModelType.EVENT} DEVICES_FOR_SUBSCRIBE = DEVICES_WITH_ENTITIES | {ModelType.EVENT}

View File

@ -12,10 +12,9 @@ from pyunifiprotect.data import (
Event, Event,
Liveview, Liveview,
ModelType, ModelType,
ProtectAdoptableDeviceModel,
ProtectDeviceModel,
WSSubscriptionMessage, WSSubscriptionMessage,
) )
from pyunifiprotect.data.base import ProtectAdoptableDeviceModel, ProtectDeviceModel
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback

View File

@ -8,6 +8,7 @@ from typing import Any
from pyunifiprotect.data import ( from pyunifiprotect.data import (
NVR, NVR,
Camera, Camera,
Chime,
Doorlock, Doorlock,
Event, Event,
Light, Light,
@ -42,7 +43,7 @@ def _async_device_entities(
entities: list[ProtectDeviceEntity] = [] entities: list[ProtectDeviceEntity] = []
for device in data.get_by_types({model_type}): for device in data.get_by_types({model_type}):
assert isinstance(device, (Camera, Light, Sensor, Viewer, Doorlock)) assert isinstance(device, (Camera, Light, Sensor, Viewer, Doorlock, Chime))
for description in descs: for description in descs:
if description.ufp_required_field: if description.ufp_required_field:
required_field = get_nested_attr(device, description.ufp_required_field) required_field = get_nested_attr(device, description.ufp_required_field)
@ -75,6 +76,7 @@ def async_all_device_entities(
sense_descs: Sequence[ProtectRequiredKeysMixin] | None = None, sense_descs: Sequence[ProtectRequiredKeysMixin] | None = None,
viewer_descs: Sequence[ProtectRequiredKeysMixin] | None = None, viewer_descs: Sequence[ProtectRequiredKeysMixin] | None = None,
lock_descs: Sequence[ProtectRequiredKeysMixin] | None = None, lock_descs: Sequence[ProtectRequiredKeysMixin] | None = None,
chime_descs: Sequence[ProtectRequiredKeysMixin] | None = None,
all_descs: Sequence[ProtectRequiredKeysMixin] | None = None, all_descs: Sequence[ProtectRequiredKeysMixin] | None = None,
) -> list[ProtectDeviceEntity]: ) -> list[ProtectDeviceEntity]:
"""Generate a list of all the device entities.""" """Generate a list of all the device entities."""
@ -84,6 +86,7 @@ def async_all_device_entities(
sense_descs = list(sense_descs or []) + all_descs sense_descs = list(sense_descs or []) + all_descs
viewer_descs = list(viewer_descs or []) + all_descs viewer_descs = list(viewer_descs or []) + all_descs
lock_descs = list(lock_descs or []) + all_descs lock_descs = list(lock_descs or []) + all_descs
chime_descs = list(chime_descs or []) + all_descs
return ( return (
_async_device_entities(data, klass, ModelType.CAMERA, camera_descs) _async_device_entities(data, klass, ModelType.CAMERA, camera_descs)
@ -91,6 +94,7 @@ def async_all_device_entities(
+ _async_device_entities(data, klass, ModelType.SENSOR, sense_descs) + _async_device_entities(data, klass, ModelType.SENSOR, sense_descs)
+ _async_device_entities(data, klass, ModelType.VIEWPORT, viewer_descs) + _async_device_entities(data, klass, ModelType.VIEWPORT, viewer_descs)
+ _async_device_entities(data, klass, ModelType.DOORLOCK, lock_descs) + _async_device_entities(data, klass, ModelType.DOORLOCK, lock_descs)
+ _async_device_entities(data, klass, ModelType.CHIME, chime_descs)
) )

View File

@ -149,6 +149,20 @@ DOORLOCK_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = (
), ),
) )
CHIME_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = (
ProtectNumberEntityDescription(
key="volume",
name="Volume",
icon="mdi:speaker",
entity_category=EntityCategory.CONFIG,
ufp_min=0,
ufp_max=100,
ufp_step=1,
ufp_value="volume",
ufp_set_method="set_volume",
),
)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -164,6 +178,7 @@ async def async_setup_entry(
light_descs=LIGHT_NUMBERS, light_descs=LIGHT_NUMBERS,
sense_descs=SENSE_NUMBERS, sense_descs=SENSE_NUMBERS,
lock_descs=DOORLOCK_NUMBERS, lock_descs=DOORLOCK_NUMBERS,
chime_descs=CHIME_NUMBERS,
) )
async_add_entities(entities) async_add_entities(entities)

View File

@ -450,6 +450,16 @@ MOTION_TRIP_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
), ),
) )
CHIME_SENSORS: tuple[ProtectSensorEntityDescription, ...] = (
ProtectSensorEntityDescription(
key="last_ring",
name="Last Ring",
device_class=SensorDeviceClass.TIMESTAMP,
icon="mdi:bell",
ufp_value="last_ring",
),
)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -466,6 +476,7 @@ async def async_setup_entry(
sense_descs=SENSE_SENSORS, sense_descs=SENSE_SENSORS,
light_descs=LIGHT_SENSORS, light_descs=LIGHT_SENSORS,
lock_descs=DOORLOCK_SENSORS, lock_descs=DOORLOCK_SENSORS,
chime_descs=CHIME_SENSORS,
) )
entities += _async_motion_entities(data) entities += _async_motion_entities(data)
entities += _async_nvr_entities(data) entities += _async_nvr_entities(data)

View File

@ -10,25 +10,32 @@ from pyunifiprotect.api import ProtectApiClient
from pyunifiprotect.exceptions import BadRequest from pyunifiprotect.exceptions import BadRequest
import voluptuous as vol import voluptuous as vol
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_DEVICE_ID from homeassistant.const import ATTR_DEVICE_ID, Platform
from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, device_registry as dr 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.helpers.service import async_extract_referenced_entity_ids
from homeassistant.util.read_only_dict import ReadOnlyDict
from .const import ATTR_MESSAGE, DOMAIN from .const import ATTR_MESSAGE, DOMAIN
from .data import ProtectData from .data import ProtectData
from .utils import _async_unifi_mac_from_hass
SERVICE_ADD_DOORBELL_TEXT = "add_doorbell_text" SERVICE_ADD_DOORBELL_TEXT = "add_doorbell_text"
SERVICE_REMOVE_DOORBELL_TEXT = "remove_doorbell_text" SERVICE_REMOVE_DOORBELL_TEXT = "remove_doorbell_text"
SERVICE_SET_DEFAULT_DOORBELL_TEXT = "set_default_doorbell_text" SERVICE_SET_DEFAULT_DOORBELL_TEXT = "set_default_doorbell_text"
SERVICE_SET_CHIME_PAIRED = "set_chime_paired_doorbells"
ALL_GLOBAL_SERIVCES = [ ALL_GLOBAL_SERIVCES = [
SERVICE_ADD_DOORBELL_TEXT, SERVICE_ADD_DOORBELL_TEXT,
SERVICE_REMOVE_DOORBELL_TEXT, SERVICE_REMOVE_DOORBELL_TEXT,
SERVICE_SET_DEFAULT_DOORBELL_TEXT, SERVICE_SET_DEFAULT_DOORBELL_TEXT,
SERVICE_SET_CHIME_PAIRED,
] ]
DOORBELL_TEXT_SCHEMA = vol.All( DOORBELL_TEXT_SCHEMA = vol.All(
@ -41,70 +48,68 @@ DOORBELL_TEXT_SCHEMA = vol.All(
cv.has_at_least_one_key(ATTR_DEVICE_ID), 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),
)
def _async_all_ufp_instances(hass: HomeAssistant) -> list[ProtectApiClient]:
"""All active UFP instances.""" def _async_ufp_instance_for_config_entry_ids(
return [ hass: HomeAssistant, config_entry_ids: set[str]
data.api for data in hass.data[DOMAIN].values() if isinstance(data, ProtectData) ) -> ProtectApiClient | None:
] """Find the UFP instance for the config entry ids."""
domain_data = hass.data[DOMAIN]
for config_entry_id in config_entry_ids:
if config_entry_id in domain_data:
protect_data: ProtectData = domain_data[config_entry_id]
return protect_data.api
return None
@callback @callback
def _async_get_macs_for_device(device_entry: dr.DeviceEntry) -> list[str]: def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiClient:
return [
_async_unifi_mac_from_hass(cval)
for ctype, cval in device_entry.connections
if ctype == dr.CONNECTION_NETWORK_MAC
]
@callback
def _async_get_ufp_instances(
hass: HomeAssistant, device_id: str
) -> tuple[dr.DeviceEntry, ProtectApiClient]:
device_registry = dr.async_get(hass) device_registry = dr.async_get(hass)
if not (device_entry := device_registry.async_get(device_id)): if not (device_entry := device_registry.async_get(device_id)):
raise HomeAssistantError(f"No device found for device id: {device_id}") raise HomeAssistantError(f"No device found for device id: {device_id}")
if device_entry.via_device_id is not None: if device_entry.via_device_id is not None:
return _async_get_ufp_instances(hass, device_entry.via_device_id) return _async_get_ufp_instance(hass, device_entry.via_device_id)
macs = _async_get_macs_for_device(device_entry) config_entry_ids = device_entry.config_entries
ufp_instances = [ if ufp_instance := _async_ufp_instance_for_config_entry_ids(hass, config_entry_ids):
i for i in _async_all_ufp_instances(hass) if i.bootstrap.nvr.mac in macs return ufp_instance
]
if not ufp_instances: raise HomeAssistantError(f"No device found for device id: {device_id}")
# should not be possible unless user manually enters a bad device ID
raise HomeAssistantError( # pragma: no cover
f"No UniFi Protect NVR found for device ID: {device_id}"
)
return device_entry, ufp_instances[0]
@callback @callback
def _async_get_protect_from_call( def _async_get_protect_from_call(
hass: HomeAssistant, call: ServiceCall hass: HomeAssistant, call: ServiceCall
) -> list[tuple[dr.DeviceEntry, ProtectApiClient]]: ) -> set[ProtectApiClient]:
referenced = async_extract_referenced_entity_ids(hass, call) return {
_async_get_ufp_instance(hass, device_id)
instances: list[tuple[dr.DeviceEntry, ProtectApiClient]] = [] for device_id in async_extract_referenced_entity_ids(
for device_id in referenced.referenced_devices: hass, call
instances.append(_async_get_ufp_instances(hass, device_id)) ).referenced_devices
}
return instances
async def _async_call_nvr( async def _async_service_call_nvr(
instances: list[tuple[dr.DeviceEntry, ProtectApiClient]], hass: HomeAssistant,
call: ServiceCall,
method: str, method: str,
*args: Any, *args: Any,
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
instances = _async_get_protect_from_call(hass, call)
try: try:
await asyncio.gather( await asyncio.gather(
*(getattr(i.bootstrap.nvr, method)(*args, **kwargs) for _, i in instances) *(getattr(i.bootstrap.nvr, method)(*args, **kwargs) for i in instances)
) )
except (BadRequest, ValidationError) as err: except (BadRequest, ValidationError) as err:
raise HomeAssistantError(str(err)) from err raise HomeAssistantError(str(err)) from err
@ -113,22 +118,61 @@ async def _async_call_nvr(
async def add_doorbell_text(hass: HomeAssistant, call: ServiceCall) -> None: async def add_doorbell_text(hass: HomeAssistant, call: ServiceCall) -> None:
"""Add a custom doorbell text message.""" """Add a custom doorbell text message."""
message: str = call.data[ATTR_MESSAGE] message: str = call.data[ATTR_MESSAGE]
instances = _async_get_protect_from_call(hass, call) await _async_service_call_nvr(hass, call, "add_custom_doorbell_message", message)
await _async_call_nvr(instances, "add_custom_doorbell_message", message)
async def remove_doorbell_text(hass: HomeAssistant, call: ServiceCall) -> None: async def remove_doorbell_text(hass: HomeAssistant, call: ServiceCall) -> None:
"""Remove a custom doorbell text message.""" """Remove a custom doorbell text message."""
message: str = call.data[ATTR_MESSAGE] message: str = call.data[ATTR_MESSAGE]
instances = _async_get_protect_from_call(hass, call) await _async_service_call_nvr(hass, call, "remove_custom_doorbell_message", message)
await _async_call_nvr(instances, "remove_custom_doorbell_message", message)
async def set_default_doorbell_text(hass: HomeAssistant, call: ServiceCall) -> None: async def set_default_doorbell_text(hass: HomeAssistant, call: ServiceCall) -> None:
"""Set the default doorbell text message.""" """Set the default doorbell text message."""
message: str = call.data[ATTR_MESSAGE] message: str = call.data[ATTR_MESSAGE]
instances = _async_get_protect_from_call(hass, call) await _async_service_call_nvr(hass, call, "set_default_doorbell_message", message)
await _async_call_nvr(instances, "set_default_doorbell_message", message)
@callback
def _async_unique_id_to_ufp_device_id(unique_id: str) -> str:
"""Extract the UFP device id 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_ufp_device_id = _async_unique_id_to_ufp_device_id(chime_button.unique_id)
instance = _async_get_ufp_instance(hass, chime_button.device_id)
chime = instance.bootstrap.chimes[chime_ufp_device_id]
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_ufp_device_id = _async_unique_id_to_ufp_device_id(
doorbell_sensor.unique_id
)
camera = instance.bootstrap.cameras[doorbell_ufp_device_id]
doorbell_ids.add(camera.id)
chime.camera_ids = sorted(doorbell_ids)
await chime.save_device()
def async_setup_services(hass: HomeAssistant) -> None: def async_setup_services(hass: HomeAssistant) -> None:
@ -149,6 +193,11 @@ def async_setup_services(hass: HomeAssistant) -> None:
functools.partial(set_default_doorbell_text, hass), functools.partial(set_default_doorbell_text, hass),
DOORBELL_TEXT_SCHEMA, DOORBELL_TEXT_SCHEMA,
), ),
(
SERVICE_SET_CHIME_PAIRED,
functools.partial(set_chime_paired_doorbells, hass),
CHIME_PAIRED_SCHEMA,
),
] ]
for name, method, schema in services: for name, method, schema in services:
if hass.services.has_service(DOMAIN, name): if hass.services.has_service(DOMAIN, name):

View File

@ -84,3 +84,28 @@ set_doorbell_message:
step: 1 step: 1
mode: slider mode: slider
unit_of_measurement: minutes unit_of_measurement: minutes
set_chime_paired_doorbells:
name: Set Chime Paired Doorbells
description: >
Use to set the paired doorbell(s) with a smart chime.
fields:
device_id:
name: Chime
description: The Chimes to link to the doorbells to
required: true
selector:
device:
integration: unifiprotect
entity:
device_class: unifiprotect__chime_button
doorbells:
name: Doorbells
description: The Doorbells to link to the chime
example: "binary_sensor.front_doorbell_doorbell"
required: false
selector:
target:
entity:
integration: unifiprotect
domain: binary_sensor
device_class: occupancy

View File

@ -14,6 +14,7 @@ import pytest
from pyunifiprotect.data import ( from pyunifiprotect.data import (
NVR, NVR,
Camera, Camera,
Chime,
Doorlock, Doorlock,
Light, Light,
Liveview, Liveview,
@ -49,6 +50,7 @@ class MockBootstrap:
liveviews: dict[str, Any] liveviews: dict[str, Any]
events: dict[str, Any] events: dict[str, Any]
doorlocks: dict[str, Any] doorlocks: dict[str, Any]
chimes: dict[str, Any]
def reset_objects(self) -> None: def reset_objects(self) -> None:
"""Reset all devices on bootstrap for tests.""" """Reset all devices on bootstrap for tests."""
@ -59,6 +61,7 @@ class MockBootstrap:
self.liveviews = {} self.liveviews = {}
self.events = {} self.events = {}
self.doorlocks = {} self.doorlocks = {}
self.chimes = {}
def process_ws_packet(self, msg: WSSubscriptionMessage) -> None: def process_ws_packet(self, msg: WSSubscriptionMessage) -> None:
"""Fake process method for tests.""" """Fake process method for tests."""
@ -127,6 +130,7 @@ def mock_bootstrap_fixture(mock_nvr: NVR):
liveviews={}, liveviews={},
events={}, events={},
doorlocks={}, doorlocks={},
chimes={},
) )
@ -220,6 +224,14 @@ def mock_doorlock():
return Doorlock.from_unifi_dict(**data) return Doorlock.from_unifi_dict(**data)
@pytest.fixture
def mock_chime():
"""Mock UniFi Protect Chime device."""
data = json.loads(load_fixture("sample_chime.json", integration=DOMAIN))
return Chime.from_unifi_dict(**data)
@pytest.fixture @pytest.fixture
def now(): def now():
"""Return datetime object that will be consistent throughout test.""" """Return datetime object that will be consistent throughout test."""

View File

@ -0,0 +1,48 @@
{
"mac": "BEEEE2FBE413",
"host": "192.168.144.146",
"connectionHost": "192.168.234.27",
"type": "UP Chime",
"name": "Xaorvu Tvsv",
"upSince": 1651882870009,
"uptime": 567870,
"lastSeen": 1652450740009,
"connectedSince": 1652448904587,
"state": "CONNECTED",
"hardwareRevision": null,
"firmwareVersion": "1.3.4",
"latestFirmwareVersion": "1.3.4",
"firmwareBuild": "58bd350.220401.1859",
"isUpdating": false,
"isAdopting": false,
"isAdopted": true,
"isAdoptedByOther": false,
"isProvisioned": false,
"isRebooting": false,
"isSshEnabled": true,
"canAdopt": false,
"isAttemptingToConnect": false,
"volume": 100,
"isProbingForWifi": false,
"apMac": null,
"apRssi": null,
"elementInfo": null,
"lastRing": 1652116059940,
"isWirelessUplinkEnabled": true,
"wiredConnectionState": {
"phyRate": null
},
"wifiConnectionState": {
"channel": null,
"frequency": null,
"phyRate": null,
"signalQuality": 100,
"signalStrength": -44,
"ssid": null
},
"cameraIds": [],
"id": "cf1a330397c08f919d02bd7c",
"isConnected": true,
"marketName": "UP Chime",
"modelKey": "chime"
}

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import pytest import pytest
from pyunifiprotect.data import Camera from pyunifiprotect.data.devices import Chime
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION
from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, Platform from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, Platform
@ -15,42 +15,39 @@ from homeassistant.helpers import entity_registry as er
from .conftest import MockEntityFixture, assert_entity_counts, enable_entity from .conftest import MockEntityFixture, assert_entity_counts, enable_entity
@pytest.fixture(name="camera") @pytest.fixture(name="chime")
async def camera_fixture( async def chime_fixture(
hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera hass: HomeAssistant, mock_entry: MockEntityFixture, mock_chime: Chime
): ):
"""Fixture for a single camera for testing the button platform.""" """Fixture for a single camera for testing the button platform."""
camera_obj = mock_camera.copy(deep=True) chime_obj = mock_chime.copy(deep=True)
camera_obj._api = mock_entry.api chime_obj._api = mock_entry.api
camera_obj.channels[0]._api = mock_entry.api chime_obj.name = "Test Chime"
camera_obj.channels[1]._api = mock_entry.api
camera_obj.channels[2]._api = mock_entry.api
camera_obj.name = "Test Camera"
mock_entry.api.bootstrap.cameras = { mock_entry.api.bootstrap.chimes = {
camera_obj.id: camera_obj, chime_obj.id: chime_obj,
} }
await hass.config_entries.async_setup(mock_entry.entry.entry_id) await hass.config_entries.async_setup(mock_entry.entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
assert_entity_counts(hass, Platform.BUTTON, 1, 0) assert_entity_counts(hass, Platform.BUTTON, 3, 2)
return (camera_obj, "button.test_camera_reboot_device") return chime_obj
async def test_button( async def test_reboot_button(
hass: HomeAssistant, hass: HomeAssistant,
mock_entry: MockEntityFixture, mock_entry: MockEntityFixture,
camera: tuple[Camera, str], chime: Chime,
): ):
"""Test button entity.""" """Test button entity."""
mock_entry.api.reboot_device = AsyncMock() mock_entry.api.reboot_device = AsyncMock()
unique_id = f"{camera[0].id}_reboot" unique_id = f"{chime.id}_reboot"
entity_id = camera[1] entity_id = "button.test_chime_reboot_device"
entity_registry = er.async_get(hass) entity_registry = er.async_get(hass)
entity = entity_registry.async_get(entity_id) entity = entity_registry.async_get(entity_id)
@ -67,3 +64,31 @@ async def test_button(
"button", "press", {ATTR_ENTITY_ID: entity_id}, blocking=True "button", "press", {ATTR_ENTITY_ID: entity_id}, blocking=True
) )
mock_entry.api.reboot_device.assert_called_once() mock_entry.api.reboot_device.assert_called_once()
async def test_chime_button(
hass: HomeAssistant,
mock_entry: MockEntityFixture,
chime: Chime,
):
"""Test button entity."""
mock_entry.api.play_speaker = AsyncMock()
unique_id = f"{chime.id}_play"
entity_id = "button.test_chime_play_chime"
entity_registry = er.async_get(hass)
entity = entity_registry.async_get(entity_id)
assert entity
assert not entity.disabled
assert entity.unique_id == unique_id
state = hass.states.get(entity_id)
assert state
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
await hass.services.async_call(
"button", "press", {ATTR_ENTITY_ID: entity_id}, blocking=True
)
mock_entry.api.play_speaker.assert_called_once()

View File

@ -202,11 +202,11 @@ async def test_migrate_reboot_button(
registry = er.async_get(hass) registry = er.async_get(hass)
registry.async_get_or_create( registry.async_get_or_create(
Platform.BUTTON, Platform.BUTTON, light1.id, config_entry=mock_entry.entry Platform.BUTTON, DOMAIN, light1.id, config_entry=mock_entry.entry
) )
registry.async_get_or_create( registry.async_get_or_create(
Platform.BUTTON, Platform.BUTTON,
Platform.BUTTON, DOMAIN,
f"{light2.id}_reboot", f"{light2.id}_reboot",
config_entry=mock_entry.entry, config_entry=mock_entry.entry,
) )
@ -218,24 +218,67 @@ async def test_migrate_reboot_button(
assert mock_entry.api.update.called assert mock_entry.api.update.called
assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac
buttons = []
for entity in er.async_entries_for_config_entry(
registry, mock_entry.entry.entry_id
):
if entity.domain == Platform.BUTTON.value:
buttons.append(entity)
print(entity.entity_id)
assert len(buttons) == 2
assert registry.async_get(f"{Platform.BUTTON}.test_light_1_reboot_device") is None
assert registry.async_get(f"{Platform.BUTTON}.test_light_1_reboot_device_2") is None assert registry.async_get(f"{Platform.BUTTON}.test_light_1_reboot_device_2") is None
light = registry.async_get(f"{Platform.BUTTON}.test_light_1_reboot_device") light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_lightid1")
assert light is not None assert light is not None
assert light.unique_id == f"{light1.id}_reboot" assert light.unique_id == f"{light1.id}_reboot"
assert registry.async_get(f"{Platform.BUTTON}.test_light_2_reboot_device") is None
assert registry.async_get(f"{Platform.BUTTON}.test_light_2_reboot_device_2") is None assert registry.async_get(f"{Platform.BUTTON}.test_light_2_reboot_device_2") is None
light = registry.async_get(f"{Platform.BUTTON}.test_light_2_reboot_device") light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_lightid2_reboot")
assert light is not None assert light is not None
assert light.unique_id == f"{light2.id}_reboot" assert light.unique_id == f"{light2.id}_reboot"
async def test_migrate_reboot_button_no_device(
hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light
):
"""Test migrating unique ID of reboot button if UniFi Protect device ID changed."""
light1 = mock_light.copy()
light1._api = mock_entry.api
light1.name = "Test Light 1"
light1.id = "lightid1"
mock_entry.api.bootstrap.lights = {
light1.id: light1,
}
mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap)
registry = er.async_get(hass)
registry.async_get_or_create(
Platform.BUTTON, DOMAIN, "lightid2", config_entry=mock_entry.entry
)
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
await hass.async_block_till_done()
assert mock_entry.entry.state == ConfigEntryState.LOADED
assert mock_entry.api.update.called
assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac
buttons = [] buttons = []
for entity in er.async_entries_for_config_entry( for entity in er.async_entries_for_config_entry(
registry, mock_entry.entry.entry_id registry, mock_entry.entry.entry_id
): ):
if entity.platform == Platform.BUTTON.value: if entity.domain == Platform.BUTTON.value:
buttons.append(entity) buttons.append(entity)
assert len(buttons) == 2 assert len(buttons) == 2
light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_lightid2")
assert light is not None
assert light.unique_id == "lightid2"
async def test_migrate_reboot_button_fail( async def test_migrate_reboot_button_fail(
hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light
@ -255,14 +298,14 @@ async def test_migrate_reboot_button_fail(
registry = er.async_get(hass) registry = er.async_get(hass)
registry.async_get_or_create( registry.async_get_or_create(
Platform.BUTTON, Platform.BUTTON,
Platform.BUTTON, DOMAIN,
light1.id, light1.id,
config_entry=mock_entry.entry, config_entry=mock_entry.entry,
suggested_object_id=light1.name, suggested_object_id=light1.name,
) )
registry.async_get_or_create( registry.async_get_or_create(
Platform.BUTTON, Platform.BUTTON,
Platform.BUTTON, DOMAIN,
f"{light1.id}_reboot", f"{light1.id}_reboot",
config_entry=mock_entry.entry, config_entry=mock_entry.entry,
suggested_object_id=light1.name, suggested_object_id=light1.name,

View File

@ -5,19 +5,21 @@ from __future__ import annotations
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
import pytest import pytest
from pyunifiprotect.data import Light from pyunifiprotect.data import Camera, Light, ModelType
from pyunifiprotect.data.devices import Chime
from pyunifiprotect.exceptions import BadRequest from pyunifiprotect.exceptions import BadRequest
from homeassistant.components.unifiprotect.const import ATTR_MESSAGE, DOMAIN from homeassistant.components.unifiprotect.const import ATTR_MESSAGE, DOMAIN
from homeassistant.components.unifiprotect.services import ( from homeassistant.components.unifiprotect.services import (
SERVICE_ADD_DOORBELL_TEXT, SERVICE_ADD_DOORBELL_TEXT,
SERVICE_REMOVE_DOORBELL_TEXT, SERVICE_REMOVE_DOORBELL_TEXT,
SERVICE_SET_CHIME_PAIRED,
SERVICE_SET_DEFAULT_DOORBELL_TEXT, SERVICE_SET_DEFAULT_DOORBELL_TEXT,
) )
from homeassistant.const import ATTR_DEVICE_ID from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr, entity_registry as er
from .conftest import MockEntityFixture from .conftest import MockEntityFixture
@ -143,3 +145,70 @@ async def test_set_default_doorbell_text(
blocking=True, blocking=True,
) )
nvr.set_default_doorbell_message.assert_called_once_with("Test Message") nvr.set_default_doorbell_message.assert_called_once_with("Test Message")
async def test_set_chime_paired_doorbells(
hass: HomeAssistant,
mock_entry: MockEntityFixture,
mock_chime: Chime,
mock_camera: Camera,
):
"""Test set_chime_paired_doorbells."""
mock_entry.api.update_device = AsyncMock()
mock_chime._api = mock_entry.api
mock_chime.name = "Test Chime"
mock_chime._initial_data = mock_chime.dict()
mock_entry.api.bootstrap.chimes = {
mock_chime.id: mock_chime,
}
camera1 = mock_camera.copy()
camera1.id = "cameraid1"
camera1.name = "Test Camera 1"
camera1._api = mock_entry.api
camera1.channels[0]._api = mock_entry.api
camera1.channels[1]._api = mock_entry.api
camera1.channels[2]._api = mock_entry.api
camera1.feature_flags.has_chime = True
camera2 = mock_camera.copy()
camera2.id = "cameraid2"
camera2.name = "Test Camera 2"
camera2._api = mock_entry.api
camera2.channels[0]._api = mock_entry.api
camera2.channels[1]._api = mock_entry.api
camera2.channels[2]._api = mock_entry.api
camera2.feature_flags.has_chime = True
mock_entry.api.bootstrap.cameras = {
camera1.id: camera1,
camera2.id: camera2,
}
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
await hass.async_block_till_done()
registry = er.async_get(hass)
chime_entry = registry.async_get("button.test_chime_play_chime")
camera_entry = registry.async_get("binary_sensor.test_camera_2_doorbell")
assert chime_entry is not None
assert camera_entry is not None
await hass.services.async_call(
DOMAIN,
SERVICE_SET_CHIME_PAIRED,
{
ATTR_DEVICE_ID: chime_entry.device_id,
"doorbells": {
ATTR_ENTITY_ID: ["binary_sensor.test_camera_1_doorbell"],
ATTR_DEVICE_ID: [camera_entry.device_id],
},
},
blocking=True,
)
mock_entry.api.update_device.assert_called_once_with(
ModelType.CHIME, mock_chime.id, {"cameraIds": [camera1.id, camera2.id]}
)