Add Reolink home hub scene select entity (#140823)

This commit is contained in:
starkillerOG 2025-03-19 14:34:49 +01:00 committed by GitHub
parent 245f0a1958
commit 334359871d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 131 additions and 1 deletions

View File

@ -350,6 +350,9 @@
}, },
"sub_bit_rate": { "sub_bit_rate": {
"default": "mdi:play-speed" "default": "mdi:play-speed"
},
"scene_mode": {
"default": "mdi:view-list"
} }
}, },
"sensor": { "sensor": {

View File

@ -30,6 +30,8 @@ from .entity import (
ReolinkChannelEntityDescription, ReolinkChannelEntityDescription,
ReolinkChimeCoordinatorEntity, ReolinkChimeCoordinatorEntity,
ReolinkChimeEntityDescription, ReolinkChimeEntityDescription,
ReolinkHostCoordinatorEntity,
ReolinkHostEntityDescription,
) )
from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error
@ -49,6 +51,18 @@ class ReolinkSelectEntityDescription(
value: Callable[[Host, int], str] | None = None value: Callable[[Host, int], str] | None = None
@dataclass(frozen=True, kw_only=True)
class ReolinkHostSelectEntityDescription(
SelectEntityDescription,
ReolinkHostEntityDescription,
):
"""A class that describes host select entities."""
get_options: Callable[[Host], list[str]]
method: Callable[[Host, str], Any]
value: Callable[[Host], str]
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class ReolinkChimeSelectEntityDescription( class ReolinkChimeSelectEntityDescription(
SelectEntityDescription, SelectEntityDescription,
@ -238,6 +252,19 @@ SELECT_ENTITIES = (
), ),
) )
HOST_SELECT_ENTITIES = (
ReolinkHostSelectEntityDescription(
key="scene_mode",
cmd_key="GetScene",
translation_key="scene_mode",
entity_category=EntityCategory.CONFIG,
get_options=lambda api: api.baichuan.scene_names,
supported=lambda api: api.supported(None, "scenes"),
value=lambda api: api.baichuan.active_scene,
method=lambda api, name: api.baichuan.set_scene(scene_name=name),
),
)
CHIME_SELECT_ENTITIES = ( CHIME_SELECT_ENTITIES = (
ReolinkChimeSelectEntityDescription( ReolinkChimeSelectEntityDescription(
key="motion_tone", key="motion_tone",
@ -300,12 +327,19 @@ async def async_setup_entry(
"""Set up a Reolink select entities.""" """Set up a Reolink select entities."""
reolink_data: ReolinkData = config_entry.runtime_data reolink_data: ReolinkData = config_entry.runtime_data
entities: list[ReolinkSelectEntity | ReolinkChimeSelectEntity] = [ entities: list[
ReolinkSelectEntity | ReolinkHostSelectEntity | ReolinkChimeSelectEntity
] = [
ReolinkSelectEntity(reolink_data, channel, entity_description) ReolinkSelectEntity(reolink_data, channel, entity_description)
for entity_description in SELECT_ENTITIES for entity_description in SELECT_ENTITIES
for channel in reolink_data.host.api.channels for channel in reolink_data.host.api.channels
if entity_description.supported(reolink_data.host.api, channel) if entity_description.supported(reolink_data.host.api, channel)
] ]
entities.extend(
ReolinkHostSelectEntity(reolink_data, entity_description)
for entity_description in HOST_SELECT_ENTITIES
if entity_description.supported(reolink_data.host.api)
)
entities.extend( entities.extend(
ReolinkChimeSelectEntity(reolink_data, chime, entity_description) ReolinkChimeSelectEntity(reolink_data, chime, entity_description)
for entity_description in CHIME_SELECT_ENTITIES for entity_description in CHIME_SELECT_ENTITIES
@ -360,6 +394,33 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity):
self.async_write_ha_state() self.async_write_ha_state()
class ReolinkHostSelectEntity(ReolinkHostCoordinatorEntity, SelectEntity):
"""Base select entity class for Reolink Host."""
entity_description: ReolinkHostSelectEntityDescription
def __init__(
self,
reolink_data: ReolinkData,
entity_description: ReolinkHostSelectEntityDescription,
) -> None:
"""Initialize Reolink select entity."""
self.entity_description = entity_description
super().__init__(reolink_data)
self._attr_options = entity_description.get_options(self._host.api)
@property
def current_option(self) -> str | None:
"""Return the current option."""
return self.entity_description.value(self._host.api)
@raise_translated_error
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.entity_description.method(self._host.api, option)
self.async_write_ha_state()
class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity): class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity):
"""Base select entity class for Reolink IP cameras.""" """Base select entity class for Reolink IP cameras."""

View File

@ -799,6 +799,15 @@
}, },
"sub_bit_rate": { "sub_bit_rate": {
"name": "Fluent bit rate" "name": "Fluent bit rate"
},
"scene_mode": {
"name": "Scene mode",
"state": {
"off": "[%key:common::state::off%]",
"disarm": "Disarmed",
"home": "Home",
"away": "Away"
}
} }
}, },
"sensor": { "sensor": {

View File

@ -145,6 +145,8 @@ def reolink_connect_class() -> Generator[MagicMock]:
host_mock.baichuan.privacy_mode.return_value = False host_mock.baichuan.privacy_mode.return_value = False
host_mock.baichuan.day_night_state.return_value = "day" host_mock.baichuan.day_night_state.return_value = "day"
host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error")
host_mock.baichuan.active_scene = "off"
host_mock.baichuan.scene_names = ["off", "home"]
host_mock.baichuan.abilities = { host_mock.baichuan.abilities = {
0: {"chnID": 0, "aitype": 34615}, 0: {"chnID": 0, "aitype": 34615},
"Host": {"pushAlarm": 7}, "Host": {"pushAlarm": 7},

View File

@ -170,6 +170,9 @@
'0': 1, '0': 1,
'null': 2, 'null': 2,
}), }),
'GetScene': dict({
'null': 1,
}),
'GetStateLight': dict({ 'GetStateLight': dict({
'null': 1, 'null': 1,
}), }),

View File

@ -104,6 +104,58 @@ async def test_play_quick_reply_message(
reolink_connect.quick_reply_dict = MagicMock() reolink_connect.quick_reply_dict = MagicMock()
async def test_host_scene_select(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
) -> None:
"""Test host select entity with scene mode."""
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()
assert config_entry.state is ConfigEntryState.LOADED
entity_id = f"{Platform.SELECT}.{TEST_NVR_NAME}_scene_mode"
assert hass.states.get(entity_id).state == "off"
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: entity_id, "option": "home"},
blocking=True,
)
reolink_connect.baichuan.set_scene.assert_called_once()
reolink_connect.baichuan.set_scene.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": "home"},
blocking=True,
)
reolink_connect.baichuan.set_scene.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": "home"},
blocking=True,
)
reolink_connect.baichuan.active_scene = "Invalid value"
freezer.tick(DEVICE_UPDATE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_UNKNOWN
reolink_connect.baichuan.set_scene.reset_mock(side_effect=True)
reolink_connect.baichuan.active_scene = "off"
async def test_chime_select( async def test_chime_select(
hass: HomeAssistant, hass: HomeAssistant,
freezer: FrozenDateTimeFactory, freezer: FrozenDateTimeFactory,