Add Reolink chime support (#122752)

pull/122962/head
starkillerOG 2024-07-31 17:04:09 +02:00 committed by GitHub
parent f764705629
commit 8c0d9a1320
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 549 additions and 54 deletions

View File

@ -186,7 +186,7 @@ async def async_remove_config_entry_device(
) -> bool:
"""Remove a device from a config entry."""
host: ReolinkHost = hass.data[DOMAIN][config_entry.entry_id].host
(device_uid, ch) = get_device_uid_and_ch(device, host)
(device_uid, ch, is_chime) = get_device_uid_and_ch(device, host)
if not host.api.is_nvr or ch is None:
_LOGGER.warning(
@ -227,20 +227,24 @@ async def async_remove_config_entry_device(
def get_device_uid_and_ch(
device: dr.DeviceEntry, host: ReolinkHost
) -> tuple[list[str], int | None]:
) -> tuple[list[str], int | None, bool]:
"""Get the channel and the split device_uid from a reolink DeviceEntry."""
device_uid = [
dev_id[1].split("_") for dev_id in device.identifiers if dev_id[0] == DOMAIN
][0]
is_chime = False
if len(device_uid) < 2:
# NVR itself
ch = None
elif device_uid[1].startswith("ch") and len(device_uid[1]) <= 5:
ch = int(device_uid[1][2:])
elif device_uid[1].startswith("chime"):
ch = int(device_uid[1][5:])
is_chime = True
else:
ch = host.api.channel_for_uid(device_uid[1])
return (device_uid, ch)
return (device_uid, ch, is_chime)
def migrate_entity_ids(
@ -251,7 +255,7 @@ def migrate_entity_ids(
devices = dr.async_entries_for_config_entry(device_reg, config_entry_id)
ch_device_ids = {}
for device in devices:
(device_uid, ch) = get_device_uid_and_ch(device, host)
(device_uid, ch, is_chime) = get_device_uid_and_ch(device, host)
if host.api.supported(None, "UID") and device_uid[0] != host.unique_id:
if ch is None:
@ -261,8 +265,8 @@ def migrate_entity_ids(
new_identifiers = {(DOMAIN, new_device_id)}
device_reg.async_update_device(device.id, new_identifiers=new_identifiers)
if ch is None:
continue # Do not consider the NVR itself
if ch is None or is_chime:
continue # Do not consider the NVR itself or chimes
ch_device_ids[device.id] = ch
if host.api.supported(ch, "UID") and device_uid[1] != host.api.camera_uid(ch):

View File

@ -117,18 +117,14 @@ async def async_setup_entry(
entities: list[ReolinkBinarySensorEntity] = []
for channel in reolink_data.host.api.channels:
entities.extend(
[
ReolinkPushBinarySensorEntity(reolink_data, channel, entity_description)
for entity_description in BINARY_PUSH_SENSORS
if entity_description.supported(reolink_data.host.api, channel)
]
ReolinkPushBinarySensorEntity(reolink_data, channel, entity_description)
for entity_description in BINARY_PUSH_SENSORS
if entity_description.supported(reolink_data.host.api, channel)
)
entities.extend(
[
ReolinkBinarySensorEntity(reolink_data, channel, entity_description)
for entity_description in BINARY_SENSORS
if entity_description.supported(reolink_data.host.api, channel)
]
ReolinkBinarySensorEntity(reolink_data, channel, entity_description)
for entity_description in BINARY_SENSORS
if entity_description.supported(reolink_data.host.api, channel)
)
async_add_entities(entities)

View File

@ -164,11 +164,9 @@ async def async_setup_entry(
if entity_description.supported(reolink_data.host.api, channel)
]
entities.extend(
[
ReolinkHostButtonEntity(reolink_data, entity_description)
for entity_description in HOST_BUTTON_ENTITIES
if entity_description.supported(reolink_data.host.api)
]
ReolinkHostButtonEntity(reolink_data, entity_description)
for entity_description in HOST_BUTTON_ENTITIES
if entity_description.supported(reolink_data.host.api)
)
async_add_entities(entities)

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from reolink_aio.api import DUAL_LENS_MODELS, Host
from reolink_aio.api import DUAL_LENS_MODELS, Chime, Host
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
@ -59,8 +59,9 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
http_s = "https" if self._host.api.use_https else "http"
self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}"
self._dev_id = self._host.unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._host.unique_id)},
identifiers={(DOMAIN, self._dev_id)},
connections={(CONNECTION_NETWORK_MAC, self._host.api.mac_address)},
name=self._host.api.nvr_name,
model=self._host.api.model,
@ -126,12 +127,14 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
if self._host.api.is_nvr:
if self._host.api.supported(dev_ch, "UID"):
dev_id = f"{self._host.unique_id}_{self._host.api.camera_uid(dev_ch)}"
self._dev_id = (
f"{self._host.unique_id}_{self._host.api.camera_uid(dev_ch)}"
)
else:
dev_id = f"{self._host.unique_id}_ch{dev_ch}"
self._dev_id = f"{self._host.unique_id}_ch{dev_ch}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, dev_id)},
identifiers={(DOMAIN, self._dev_id)},
via_device=(DOMAIN, self._host.unique_id),
name=self._host.api.camera_name(dev_ch),
model=self._host.api.camera_model(dev_ch),
@ -156,3 +159,34 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
self._host.async_unregister_update_cmd(cmd_key, self._channel)
await super().async_will_remove_from_hass()
class ReolinkChimeCoordinatorEntity(ReolinkChannelCoordinatorEntity):
"""Parent class for Reolink chime entities connected."""
def __init__(
self,
reolink_data: ReolinkData,
chime: Chime,
coordinator: DataUpdateCoordinator[None] | None = None,
) -> None:
"""Initialize ReolinkChimeCoordinatorEntity for a chime."""
super().__init__(reolink_data, chime.channel, coordinator)
self._chime = chime
self._attr_unique_id = (
f"{self._host.unique_id}_chime{chime.dev_id}_{self.entity_description.key}"
)
cam_dev_id = self._dev_id
self._dev_id = f"{self._host.unique_id}_chime{chime.dev_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._dev_id)},
via_device=(DOMAIN, cam_dev_id),
name=chime.name,
model="Reolink Chime",
manufacturer=self._host.api.manufacturer,
serial_number=str(chime.dev_id),
configuration_url=self._conf_url,
)

View File

@ -206,6 +206,15 @@
},
"hdr": {
"default": "mdi:hdr"
},
"motion_tone": {
"default": "mdi:music-note"
},
"people_tone": {
"default": "mdi:music-note"
},
"visitor_tone": {
"default": "mdi:music-note"
}
},
"sensor": {
@ -284,6 +293,9 @@
},
"pir_reduce_alarm": {
"default": "mdi:motion-sensor"
},
"led": {
"default": "mdi:lightning-bolt-circle"
}
}
},

View File

@ -6,7 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from reolink_aio.api import Host
from reolink_aio.api import Chime, Host
from reolink_aio.exceptions import InvalidParameterError, ReolinkError
from homeassistant.components.number import (
@ -22,7 +22,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ReolinkData
from .const import DOMAIN
from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription
from .entity import (
ReolinkChannelCoordinatorEntity,
ReolinkChannelEntityDescription,
ReolinkChimeCoordinatorEntity,
)
@dataclass(frozen=True, kw_only=True)
@ -39,6 +43,18 @@ class ReolinkNumberEntityDescription(
value: Callable[[Host, int], float | None]
@dataclass(frozen=True, kw_only=True)
class ReolinkChimeNumberEntityDescription(
NumberEntityDescription,
ReolinkChannelEntityDescription,
):
"""A class that describes number entities for a chime."""
method: Callable[[Chime, float], Any]
mode: NumberMode = NumberMode.AUTO
value: Callable[[Chime], float | None]
NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="zoom",
@ -459,6 +475,20 @@ NUMBER_ENTITIES = (
),
)
CHIME_NUMBER_ENTITIES = (
ReolinkChimeNumberEntityDescription(
key="volume",
cmd_key="DingDongOpt",
translation_key="volume",
entity_category=EntityCategory.CONFIG,
native_step=1,
native_min_value=0,
native_max_value=4,
value=lambda chime: chime.volume,
method=lambda chime, value: chime.set_option(volume=int(value)),
),
)
async def async_setup_entry(
hass: HomeAssistant,
@ -468,12 +498,18 @@ async def async_setup_entry(
"""Set up a Reolink number entities."""
reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
entities: list[ReolinkNumberEntity | ReolinkChimeNumberEntity] = [
ReolinkNumberEntity(reolink_data, channel, entity_description)
for entity_description in NUMBER_ENTITIES
for channel in reolink_data.host.api.channels
if entity_description.supported(reolink_data.host.api, channel)
]
entities.extend(
ReolinkChimeNumberEntity(reolink_data, chime, entity_description)
for entity_description in CHIME_NUMBER_ENTITIES
for chime in reolink_data.host.api.chime_list
)
async_add_entities(entities)
class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity):
@ -515,3 +551,36 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity):
except ReolinkError as err:
raise HomeAssistantError(err) from err
self.async_write_ha_state()
class ReolinkChimeNumberEntity(ReolinkChimeCoordinatorEntity, NumberEntity):
"""Base number entity class for Reolink IP cameras."""
entity_description: ReolinkChimeNumberEntityDescription
def __init__(
self,
reolink_data: ReolinkData,
chime: Chime,
entity_description: ReolinkChimeNumberEntityDescription,
) -> None:
"""Initialize Reolink chime number entity."""
self.entity_description = entity_description
super().__init__(reolink_data, chime)
self._attr_mode = entity_description.mode
@property
def native_value(self) -> float | None:
"""State of the number entity."""
return self.entity_description.value(self._chime)
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
try:
await self.entity_description.method(self._chime, value)
except InvalidParameterError as err:
raise ServiceValidationError(err) from err
except ReolinkError as err:
raise HomeAssistantError(err) from err
self.async_write_ha_state()

View File

@ -8,6 +8,8 @@ import logging
from typing import Any
from reolink_aio.api import (
Chime,
ChimeToneEnum,
DayNightEnum,
HDREnum,
Host,
@ -26,7 +28,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import ReolinkData
from .const import DOMAIN
from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription
from .entity import (
ReolinkChannelCoordinatorEntity,
ReolinkChannelEntityDescription,
ReolinkChimeCoordinatorEntity,
)
_LOGGER = logging.getLogger(__name__)
@ -43,6 +49,18 @@ class ReolinkSelectEntityDescription(
value: Callable[[Host, int], str] | None = None
@dataclass(frozen=True, kw_only=True)
class ReolinkChimeSelectEntityDescription(
SelectEntityDescription,
ReolinkChannelEntityDescription,
):
"""A class that describes select entities for a chime."""
get_options: list[str]
method: Callable[[Chime, str], Any]
value: Callable[[Chime], str]
def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int:
"""Get the quick reply file id from the message string."""
return [k for k, v in api.quick_reply_dict(ch).items() if v == mess][0]
@ -132,6 +150,36 @@ SELECT_ENTITIES = (
),
)
CHIME_SELECT_ENTITIES = (
ReolinkChimeSelectEntityDescription(
key="motion_tone",
cmd_key="GetDingDongCfg",
translation_key="motion_tone",
entity_category=EntityCategory.CONFIG,
get_options=[method.name for method in ChimeToneEnum],
value=lambda chime: ChimeToneEnum(chime.tone("md")).name,
method=lambda chime, name: chime.set_tone("md", ChimeToneEnum[name].value),
),
ReolinkChimeSelectEntityDescription(
key="people_tone",
cmd_key="GetDingDongCfg",
translation_key="people_tone",
entity_category=EntityCategory.CONFIG,
get_options=[method.name for method in ChimeToneEnum],
value=lambda chime: ChimeToneEnum(chime.tone("people")).name,
method=lambda chime, name: chime.set_tone("people", ChimeToneEnum[name].value),
),
ReolinkChimeSelectEntityDescription(
key="visitor_tone",
cmd_key="GetDingDongCfg",
translation_key="visitor_tone",
entity_category=EntityCategory.CONFIG,
get_options=[method.name for method in ChimeToneEnum],
value=lambda chime: ChimeToneEnum(chime.tone("visitor")).name,
method=lambda chime, name: chime.set_tone("visitor", ChimeToneEnum[name].value),
),
)
async def async_setup_entry(
hass: HomeAssistant,
@ -141,12 +189,18 @@ async def async_setup_entry(
"""Set up a Reolink select entities."""
reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities(
entities: list[ReolinkSelectEntity | ReolinkChimeSelectEntity] = [
ReolinkSelectEntity(reolink_data, channel, entity_description)
for entity_description in SELECT_ENTITIES
for channel in reolink_data.host.api.channels
if entity_description.supported(reolink_data.host.api, channel)
]
entities.extend(
ReolinkChimeSelectEntity(reolink_data, chime, entity_description)
for entity_description in CHIME_SELECT_ENTITIES
for chime in reolink_data.host.api.chime_list
)
async_add_entities(entities)
class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity):
@ -196,3 +250,45 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity):
except ReolinkError as err:
raise HomeAssistantError(err) from err
self.async_write_ha_state()
class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity):
"""Base select entity class for Reolink IP cameras."""
entity_description: ReolinkChimeSelectEntityDescription
def __init__(
self,
reolink_data: ReolinkData,
chime: Chime,
entity_description: ReolinkChimeSelectEntityDescription,
) -> None:
"""Initialize Reolink select entity for a chime."""
self.entity_description = entity_description
super().__init__(reolink_data, chime)
self._log_error = True
self._attr_options = entity_description.get_options
@property
def current_option(self) -> str | None:
"""Return the current option."""
try:
option = self.entity_description.value(self._chime)
except ValueError:
if self._log_error:
_LOGGER.exception("Reolink '%s' has an unknown value", self.name)
self._log_error = False
return None
self._log_error = True
return option
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
try:
await self.entity_description.method(self._chime, option)
except InvalidParameterError as err:
raise ServiceValidationError(err) from err
except ReolinkError as err:
raise HomeAssistantError(err) from err
self.async_write_ha_state()

View File

@ -141,19 +141,15 @@ async def async_setup_entry(
if entity_description.supported(reolink_data.host.api, channel)
]
entities.extend(
[
ReolinkHostSensorEntity(reolink_data, entity_description)
for entity_description in HOST_SENSORS
if entity_description.supported(reolink_data.host.api)
]
ReolinkHostSensorEntity(reolink_data, entity_description)
for entity_description in HOST_SENSORS
if entity_description.supported(reolink_data.host.api)
)
entities.extend(
[
ReolinkHddSensorEntity(reolink_data, hdd_index, entity_description)
for entity_description in HDD_SENSORS
for hdd_index in reolink_data.host.api.hdd_list
if entity_description.supported(reolink_data.host.api, hdd_index)
]
ReolinkHddSensorEntity(reolink_data, hdd_index, entity_description)
for entity_description in HDD_SENSORS
for hdd_index in reolink_data.host.api.hdd_list
if entity_description.supported(reolink_data.host.api, hdd_index)
)
async_add_entities(entities)

View File

@ -491,6 +491,54 @@
"on": "[%key:common::state::on%]",
"auto": "Auto"
}
},
"motion_tone": {
"name": "Motion ringtone",
"state": {
"off": "[%key:common::state::off%]",
"citybird": "City bird",
"originaltune": "Original tune",
"pianokey": "Piano key",
"loop": "Loop",
"attraction": "Attraction",
"hophop": "Hop hop",
"goodday": "Good day",
"operetta": "Operetta",
"moonlight": "Moonlight",
"waybackhome": "Way back home"
}
},
"people_tone": {
"name": "Person ringtone",
"state": {
"off": "[%key:common::state::off%]",
"citybird": "[%key:component::reolink::entity::select::motion_tone::state::citybird%]",
"originaltune": "[%key:component::reolink::entity::select::motion_tone::state::originaltune%]",
"pianokey": "[%key:component::reolink::entity::select::motion_tone::state::pianokey%]",
"loop": "[%key:component::reolink::entity::select::motion_tone::state::loop%]",
"attraction": "[%key:component::reolink::entity::select::motion_tone::state::attraction%]",
"hophop": "[%key:component::reolink::entity::select::motion_tone::state::hophop%]",
"goodday": "[%key:component::reolink::entity::select::motion_tone::state::goodday%]",
"operetta": "[%key:component::reolink::entity::select::motion_tone::state::operetta%]",
"moonlight": "[%key:component::reolink::entity::select::motion_tone::state::moonlight%]",
"waybackhome": "[%key:component::reolink::entity::select::motion_tone::state::waybackhome%]"
}
},
"visitor_tone": {
"name": "Visitor ringtone",
"state": {
"off": "[%key:common::state::off%]",
"citybird": "[%key:component::reolink::entity::select::motion_tone::state::citybird%]",
"originaltune": "[%key:component::reolink::entity::select::motion_tone::state::originaltune%]",
"pianokey": "[%key:component::reolink::entity::select::motion_tone::state::pianokey%]",
"loop": "[%key:component::reolink::entity::select::motion_tone::state::loop%]",
"attraction": "[%key:component::reolink::entity::select::motion_tone::state::attraction%]",
"hophop": "[%key:component::reolink::entity::select::motion_tone::state::hophop%]",
"goodday": "[%key:component::reolink::entity::select::motion_tone::state::goodday%]",
"operetta": "[%key:component::reolink::entity::select::motion_tone::state::operetta%]",
"moonlight": "[%key:component::reolink::entity::select::motion_tone::state::moonlight%]",
"waybackhome": "[%key:component::reolink::entity::select::motion_tone::state::waybackhome%]"
}
}
},
"sensor": {
@ -574,6 +622,9 @@
},
"pir_reduce_alarm": {
"name": "PIR reduce false alarm"
},
"led": {
"name": "LED"
}
}
}

View File

@ -6,7 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from reolink_aio.api import Host
from reolink_aio.api import Chime, Host
from reolink_aio.exceptions import ReolinkError
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
@ -22,6 +22,7 @@ from .const import DOMAIN
from .entity import (
ReolinkChannelCoordinatorEntity,
ReolinkChannelEntityDescription,
ReolinkChimeCoordinatorEntity,
ReolinkHostCoordinatorEntity,
ReolinkHostEntityDescription,
)
@ -49,6 +50,17 @@ class ReolinkNVRSwitchEntityDescription(
value: Callable[[Host], bool]
@dataclass(frozen=True, kw_only=True)
class ReolinkChimeSwitchEntityDescription(
SwitchEntityDescription,
ReolinkChannelEntityDescription,
):
"""A class that describes switch entities for a chime."""
method: Callable[[Chime, bool], Any]
value: Callable[[Chime], bool | None]
SWITCH_ENTITIES = (
ReolinkSwitchEntityDescription(
key="ir_lights",
@ -245,6 +257,17 @@ NVR_SWITCH_ENTITIES = (
),
)
CHIME_SWITCH_ENTITIES = (
ReolinkChimeSwitchEntityDescription(
key="chime_led",
cmd_key="DingDongOpt",
translation_key="led",
entity_category=EntityCategory.CONFIG,
value=lambda chime: chime.led_state,
method=lambda chime, value: chime.set_option(led=value),
),
)
# Can be removed in HA 2025.2.0
DEPRECATED_HDR = ReolinkSwitchEntityDescription(
key="hdr",
@ -266,18 +289,23 @@ async def async_setup_entry(
"""Set up a Reolink switch entities."""
reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id]
entities: list[ReolinkSwitchEntity | ReolinkNVRSwitchEntity] = [
entities: list[
ReolinkSwitchEntity | ReolinkNVRSwitchEntity | ReolinkChimeSwitchEntity
] = [
ReolinkSwitchEntity(reolink_data, channel, entity_description)
for entity_description in SWITCH_ENTITIES
for channel in reolink_data.host.api.channels
if entity_description.supported(reolink_data.host.api, channel)
]
entities.extend(
[
ReolinkNVRSwitchEntity(reolink_data, entity_description)
for entity_description in NVR_SWITCH_ENTITIES
if entity_description.supported(reolink_data.host.api)
]
ReolinkNVRSwitchEntity(reolink_data, entity_description)
for entity_description in NVR_SWITCH_ENTITIES
if entity_description.supported(reolink_data.host.api)
)
entities.extend(
ReolinkChimeSwitchEntity(reolink_data, chime, entity_description)
for entity_description in CHIME_SWITCH_ENTITIES
for chime in reolink_data.host.api.chime_list
)
# Can be removed in HA 2025.2.0
@ -378,3 +406,40 @@ class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity):
except ReolinkError as err:
raise HomeAssistantError(err) from err
self.async_write_ha_state()
class ReolinkChimeSwitchEntity(ReolinkChimeCoordinatorEntity, SwitchEntity):
"""Base switch entity class for a chime."""
entity_description: ReolinkChimeSwitchEntityDescription
def __init__(
self,
reolink_data: ReolinkData,
chime: Chime,
entity_description: ReolinkChimeSwitchEntityDescription,
) -> None:
"""Initialize Reolink switch entity."""
self.entity_description = entity_description
super().__init__(reolink_data, chime)
@property
def is_on(self) -> bool | None:
"""Return true if switch is on."""
return self.entity_description.value(self._chime)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
try:
await self.entity_description.method(self._chime, True)
except ReolinkError as err:
raise HomeAssistantError(err) from err
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
try:
await self.entity_description.method(self._chime, False)
except ReolinkError as err:
raise HomeAssistantError(err) from err
self.async_write_ha_state()

View File

@ -81,11 +81,9 @@ async def async_setup_entry(
if entity_description.supported(reolink_data.host.api, channel)
]
entities.extend(
[
ReolinkHostUpdateEntity(reolink_data, entity_description)
for entity_description in HOST_UPDATE_ENTITIES
if entity_description.supported(reolink_data.host.api)
]
ReolinkHostUpdateEntity(reolink_data, entity_description)
for entity_description in HOST_UPDATE_ENTITIES
if entity_description.supported(reolink_data.host.api)
)
async_add_entities(entities)

View File

@ -282,6 +282,15 @@ async def test_removing_disconnected_cams(
True,
False,
),
(
f"{TEST_MAC}_chime123456789_play_ringtone",
f"{TEST_UID}_chime123456789_play_ringtone",
f"{TEST_MAC}_chime123456789",
f"{TEST_UID}_chime123456789",
Platform.SELECT,
True,
False,
),
(
f"{TEST_MAC}_0_record_audio",
f"{TEST_MAC}_{TEST_UID_CAM}_record_audio",

View File

@ -0,0 +1,167 @@
"""Test the Reolink select platform."""
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from reolink_aio.api import Chime
from reolink_aio.exceptions import InvalidParameterError, ReolinkError
from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_SELECT_OPTION,
STATE_UNKNOWN,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from homeassistant.util.dt import utcnow
from .conftest import TEST_NVR_NAME
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_floodlight_mode_select(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test select entity with floodlight_mode."""
reolink_connect.whiteled_mode.return_value = 1
reolink_connect.whiteled_mode_list.return_value = ["off", "auto"]
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]):
assert await hass.config_entries.async_setup(config_entry.entry_id) is True
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
entity_id = f"{Platform.SELECT}.{TEST_NVR_NAME}_floodlight_mode"
assert hass.states.is_state(entity_id, "auto")
reolink_connect.set_whiteled = AsyncMock()
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: entity_id, "option": "off"},
blocking=True,
)
reolink_connect.set_whiteled.assert_called_once()
reolink_connect.set_whiteled = AsyncMock(side_effect=ReolinkError("Test error"))
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: entity_id, "option": "off"},
blocking=True,
)
reolink_connect.set_whiteled = AsyncMock(
side_effect=InvalidParameterError("Test error")
)
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: entity_id, "option": "off"},
blocking=True,
)
async def test_play_quick_reply_message(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test select play_quick_reply_message entity."""
reolink_connect.quick_reply_dict.return_value = {0: "off", 1: "test message"}
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]):
assert await hass.config_entries.async_setup(config_entry.entry_id) is True
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
entity_id = f"{Platform.SELECT}.{TEST_NVR_NAME}_play_quick_reply_message"
assert hass.states.is_state(entity_id, STATE_UNKNOWN)
reolink_connect.play_quick_reply = AsyncMock()
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: entity_id, "option": "test message"},
blocking=True,
)
reolink_connect.play_quick_reply.assert_called_once()
async def test_chime_select(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test chime select entity."""
TEST_CHIME = Chime(
host=reolink_connect,
dev_id=12345678,
channel=0,
name="Test chime",
event_info={
"md": {"switch": 0, "musicId": 0},
"people": {"switch": 0, "musicId": 1},
"visitor": {"switch": 1, "musicId": 2},
},
)
TEST_CHIME.volume = 3
TEST_CHIME.led_state = True
reolink_connect.chime_list = [TEST_CHIME]
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]):
assert await hass.config_entries.async_setup(config_entry.entry_id) is True
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
entity_id = f"{Platform.SELECT}.test_chime_visitor_ringtone"
assert hass.states.is_state(entity_id, "pianokey")
TEST_CHIME.set_tone = AsyncMock()
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: entity_id, "option": "off"},
blocking=True,
)
TEST_CHIME.set_tone.assert_called_once()
TEST_CHIME.set_tone = AsyncMock(side_effect=ReolinkError("Test error"))
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: entity_id, "option": "off"},
blocking=True,
)
TEST_CHIME.set_tone = AsyncMock(side_effect=InvalidParameterError("Test error"))
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: entity_id, "option": "off"},
blocking=True,
)
TEST_CHIME.event_info = {}
async_fire_time_changed(
hass, utcnow() + DEVICE_UPDATE_INTERVAL + timedelta(seconds=30)
)
await hass.async_block_till_done()
assert hass.states.is_state(entity_id, STATE_UNKNOWN)