Add support for Reolink chime connected to Home Hub (#151199)

This commit is contained in:
starkillerOG
2025-09-24 18:07:23 +02:00
committed by GitHub
parent 2844bd474a
commit 6dd33f900d
8 changed files with 176 additions and 29 deletions
+41 -4
View File
@@ -243,8 +243,45 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
await super().async_will_remove_from_hass()
class ReolinkHostChimeCoordinatorEntity(ReolinkHostCoordinatorEntity):
"""Parent class for Reolink chime entities connected to a Host."""
def __init__(
self,
reolink_data: ReolinkData,
chime: Chime,
coordinator: DataUpdateCoordinator[None] | None = None,
) -> None:
"""Initialize ReolinkChimeCoordinatorEntity for a chime."""
super().__init__(reolink_data, coordinator)
self._channel = chime.channel
self._chime = chime
self._attr_unique_id = (
f"{self._host.unique_id}_chime{chime.dev_id}_{self.entity_description.key}"
)
via_dev_id = self._host.unique_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, via_dev_id),
name=chime.name,
model="Reolink Chime",
manufacturer=self._host.api.manufacturer,
sw_version=chime.sw_version,
serial_number=str(chime.dev_id),
configuration_url=self._conf_url,
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self._chime.online
class ReolinkChimeCoordinatorEntity(ReolinkChannelCoordinatorEntity):
"""Parent class for Reolink chime entities connected."""
"""Parent class for Reolink chime entities connected through a camera."""
def __init__(
self,
@@ -255,21 +292,21 @@ class ReolinkChimeCoordinatorEntity(ReolinkChannelCoordinatorEntity):
"""Initialize ReolinkChimeCoordinatorEntity for a chime."""
assert chime.channel is not None
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
via_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),
via_device=(DOMAIN, via_dev_id),
name=chime.name,
model="Reolink Chime",
manufacturer=self._host.api.manufacturer,
sw_version=chime.sw_version,
serial_number=str(chime.dev_id),
configuration_url=self._conf_url,
)
+37 -1
View File
@@ -23,6 +23,7 @@ from .entity import (
ReolinkChannelEntityDescription,
ReolinkChimeCoordinatorEntity,
ReolinkChimeEntityDescription,
ReolinkHostChimeCoordinatorEntity,
ReolinkHostCoordinatorEntity,
ReolinkHostEntityDescription,
)
@@ -855,6 +856,12 @@ async def async_setup_entry(
for chime in api.chime_list
if chime.channel is not None
)
entities.extend(
ReolinkHostChimeNumberEntity(reolink_data, chime, entity_description)
for entity_description in CHIME_NUMBER_ENTITIES
for chime in api.chime_list
if chime.channel is None
)
async_add_entities(entities)
@@ -969,7 +976,36 @@ class ReolinkHostNumberEntity(ReolinkHostCoordinatorEntity, NumberEntity):
class ReolinkChimeNumberEntity(ReolinkChimeCoordinatorEntity, NumberEntity):
"""Base number entity class for Reolink IP cameras."""
"""Base number entity class for Reolink chimes connected through a camera."""
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)
@raise_translated_error
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
await self.entity_description.method(self._chime, value)
self.async_write_ha_state()
class ReolinkHostChimeNumberEntity(ReolinkHostChimeCoordinatorEntity, NumberEntity):
"""Base number entity class for Reolink chimes connected to the host."""
entity_description: ReolinkChimeNumberEntityDescription
+44 -21
View File
@@ -31,6 +31,7 @@ from .entity import (
ReolinkChannelEntityDescription,
ReolinkChimeCoordinatorEntity,
ReolinkChimeEntityDescription,
ReolinkHostChimeCoordinatorEntity,
ReolinkHostCoordinatorEntity,
ReolinkHostEntityDescription,
)
@@ -73,7 +74,7 @@ class ReolinkChimeSelectEntityDescription(
get_options: list[str]
method: Callable[[Chime, str], Any]
value: Callable[[Chime], str]
value: Callable[[Chime], str | None]
def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int:
@@ -332,7 +333,7 @@ CHIME_SELECT_ENTITIES = (
entity_category=EntityCategory.CONFIG,
supported=lambda chime: "md" in chime.chime_event_types,
get_options=[method.name for method in ChimeToneEnum],
value=lambda chime: ChimeToneEnum(chime.tone("md")).name,
value=lambda chime: chime.tone_name("md"),
method=lambda chime, name: chime.set_tone("md", ChimeToneEnum[name].value),
),
ReolinkChimeSelectEntityDescription(
@@ -342,7 +343,7 @@ CHIME_SELECT_ENTITIES = (
entity_category=EntityCategory.CONFIG,
get_options=[method.name for method in ChimeToneEnum],
supported=lambda chime: "people" in chime.chime_event_types,
value=lambda chime: ChimeToneEnum(chime.tone("people")).name,
value=lambda chime: chime.tone_name("people"),
method=lambda chime, name: chime.set_tone("people", ChimeToneEnum[name].value),
),
ReolinkChimeSelectEntityDescription(
@@ -352,7 +353,7 @@ CHIME_SELECT_ENTITIES = (
entity_category=EntityCategory.CONFIG,
get_options=[method.name for method in ChimeToneEnum],
supported=lambda chime: "vehicle" in chime.chime_event_types,
value=lambda chime: ChimeToneEnum(chime.tone("vehicle")).name,
value=lambda chime: chime.tone_name("vehicle"),
method=lambda chime, name: chime.set_tone("vehicle", ChimeToneEnum[name].value),
),
ReolinkChimeSelectEntityDescription(
@@ -362,7 +363,7 @@ CHIME_SELECT_ENTITIES = (
entity_category=EntityCategory.CONFIG,
get_options=[method.name for method in ChimeToneEnum],
supported=lambda chime: "visitor" in chime.chime_event_types,
value=lambda chime: ChimeToneEnum(chime.tone("visitor")).name,
value=lambda chime: chime.tone_name("visitor"),
method=lambda chime, name: chime.set_tone("visitor", ChimeToneEnum[name].value),
),
ReolinkChimeSelectEntityDescription(
@@ -372,7 +373,7 @@ CHIME_SELECT_ENTITIES = (
entity_category=EntityCategory.CONFIG,
get_options=[method.name for method in ChimeToneEnum],
supported=lambda chime: "package" in chime.chime_event_types,
value=lambda chime: ChimeToneEnum(chime.tone("package")).name,
value=lambda chime: chime.tone_name("package"),
method=lambda chime, name: chime.set_tone("package", ChimeToneEnum[name].value),
),
)
@@ -386,9 +387,7 @@ async def async_setup_entry(
"""Set up a Reolink select entities."""
reolink_data: ReolinkData = config_entry.runtime_data
entities: list[
ReolinkSelectEntity | ReolinkHostSelectEntity | ReolinkChimeSelectEntity
] = [
entities: list[SelectEntity] = [
ReolinkSelectEntity(reolink_data, channel, entity_description)
for entity_description in SELECT_ENTITIES
for channel in reolink_data.host.api.channels
@@ -405,6 +404,12 @@ async def async_setup_entry(
for chime in reolink_data.host.api.chime_list
if entity_description.supported(chime) and chime.channel is not None
)
entities.extend(
ReolinkHostChimeSelectEntity(reolink_data, chime, entity_description)
for entity_description in CHIME_SELECT_ENTITIES
for chime in reolink_data.host.api.chime_list
if entity_description.supported(chime) and chime.channel is None
)
async_add_entities(entities)
@@ -481,7 +486,7 @@ class ReolinkHostSelectEntity(ReolinkHostCoordinatorEntity, SelectEntity):
class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity):
"""Base select entity class for Reolink IP cameras."""
"""Base select entity class for Reolink chimes connected through a camera."""
entity_description: ReolinkChimeSelectEntityDescription
@@ -494,22 +499,40 @@ class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity):
"""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, KeyError):
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
return self.entity_description.value(self._chime)
@raise_translated_error
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.entity_description.method(self._chime, option)
self.async_write_ha_state()
class ReolinkHostChimeSelectEntity(ReolinkHostChimeCoordinatorEntity, SelectEntity):
"""Base select entity class for Reolink chimes connected to a host."""
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._attr_options = entity_description.get_options
@property
def current_option(self) -> str | None:
"""Return the current option."""
return self.entity_description.value(self._chime)
@raise_translated_error
async def async_select_option(self, option: str) -> None:
+41 -3
View File
@@ -20,6 +20,7 @@ from .entity import (
ReolinkChannelEntityDescription,
ReolinkChimeCoordinatorEntity,
ReolinkChimeEntityDescription,
ReolinkHostChimeCoordinatorEntity,
ReolinkHostCoordinatorEntity,
ReolinkHostEntityDescription,
)
@@ -364,9 +365,7 @@ async def async_setup_entry(
"""Set up a Reolink switch entities."""
reolink_data: ReolinkData = config_entry.runtime_data
entities: list[
ReolinkSwitchEntity | ReolinkNVRSwitchEntity | ReolinkChimeSwitchEntity
] = [
entities: list[SwitchEntity] = [
ReolinkSwitchEntity(reolink_data, channel, entity_description)
for entity_description in SWITCH_ENTITIES
for channel in reolink_data.host.api.channels
@@ -383,6 +382,12 @@ async def async_setup_entry(
for chime in reolink_data.host.api.chime_list
if chime.channel is not None
)
entities.extend(
ReolinkHostChimeSwitchEntity(reolink_data, chime, entity_description)
for entity_description in CHIME_SWITCH_ENTITIES
for chime in reolink_data.host.api.chime_list
if chime.channel is None
)
# Can be removed in HA 2025.4.0
depricated_dict = {}
@@ -511,3 +516,36 @@ class ReolinkChimeSwitchEntity(ReolinkChimeCoordinatorEntity, SwitchEntity):
"""Turn the entity off."""
await self.entity_description.method(self._chime, False)
self.async_write_ha_state()
class ReolinkHostChimeSwitchEntity(ReolinkHostChimeCoordinatorEntity, 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)
@raise_translated_error
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
await self.entity_description.method(self._chime, True)
self.async_write_ha_state()
@raise_translated_error
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.entity_description.method(self._chime, False)
self.async_write_ha_state()
+1
View File
@@ -252,6 +252,7 @@ def reolink_chime(reolink_host: MagicMock) -> None:
}
TEST_CHIME.remove = AsyncMock()
TEST_CHIME.set_option = AsyncMock()
TEST_CHIME.update_enums()
reolink_host.chime_list = [TEST_CHIME]
reolink_host.chime.return_value = TEST_CHIME
+3
View File
@@ -147,13 +147,16 @@ async def test_host_number(
)
@pytest.mark.parametrize("channel", [0, None])
async def test_chime_number(
hass: HomeAssistant,
config_entry: MockConfigEntry,
reolink_host: MagicMock,
reolink_chime: Chime,
channel: int | None,
) -> None:
"""Test number entity of a chime with chime volume."""
reolink_chime.channel = channel
reolink_chime.volume = 3
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.NUMBER]):
+5
View File
@@ -149,6 +149,7 @@ async def test_host_scene_select(
assert hass.states.get(entity_id).state == STATE_UNKNOWN
@pytest.mark.parametrize("channel", [0, None])
async def test_chime_select(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
@@ -156,8 +157,11 @@ async def test_chime_select(
reolink_host: MagicMock,
reolink_chime: Chime,
entity_registry: er.EntityRegistry,
channel: int | None,
) -> None:
"""Test chime select entity."""
reolink_chime.channel = channel
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SELECT]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
@@ -197,6 +201,7 @@ async def test_chime_select(
# Test unavailable
reolink_chime.event_info = {}
reolink_chime.update_enums()
freezer.tick(DEVICE_UPDATE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
+4
View File
@@ -164,14 +164,18 @@ async def test_host_switch(
)
@pytest.mark.parametrize("channel", [0, None])
async def test_chime_switch(
hass: HomeAssistant,
config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
reolink_host: MagicMock,
reolink_chime: Chime,
channel: int | None,
) -> None:
"""Test host switch entity."""
reolink_chime.channel = channel
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()