diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 7d290dc6f0ad..dcda6b843ad1 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -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, ) diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index e7575c207e99..aaf503d70f8a 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -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 diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 7c9510387992..4ce7866625d7 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -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: diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index bf18be7b837f..d5f45872661e 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -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() diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 2911c851dae1..f40bfa839852 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -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 diff --git a/tests/components/reolink/test_number.py b/tests/components/reolink/test_number.py index 853edeefa5a1..3e49a5dd4a78 100644 --- a/tests/components/reolink/test_number.py +++ b/tests/components/reolink/test_number.py @@ -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]): diff --git a/tests/components/reolink/test_select.py b/tests/components/reolink/test_select.py index 5dcce7475186..e74bcf8fc753 100644 --- a/tests/components/reolink/test_select.py +++ b/tests/components/reolink/test_select.py @@ -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() diff --git a/tests/components/reolink/test_switch.py b/tests/components/reolink/test_switch.py index c8a38f19d5ca..97dfc622aed0 100644 --- a/tests/components/reolink/test_switch.py +++ b/tests/components/reolink/test_switch.py @@ -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()