Add Reolink chime support (#122752)
parent
f764705629
commit
8c0d9a1320
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
Loading…
Reference in New Issue