mirror of
https://github.com/home-assistant/core.git
synced 2026-09-15 04:58:20 +01:00
Add Reolink sub channel support (#181570)
This commit is contained in:
@@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from .const import SUPPORT_PTZ_SPEED
|
||||
from .entity import (
|
||||
ReolinkChannelCoordinatorEntity,
|
||||
ReolinkChannelEntityDescription,
|
||||
ReolinkEntityDescription,
|
||||
ReolinkHostCoordinatorEntity,
|
||||
ReolinkHostEntityDescription,
|
||||
)
|
||||
@@ -30,12 +30,13 @@ PARALLEL_UPDATES = 0
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ReolinkButtonEntityDescription(
|
||||
ButtonEntityDescription,
|
||||
ReolinkChannelEntityDescription,
|
||||
ReolinkEntityDescription,
|
||||
):
|
||||
"""A class that describes button entities for a camera channel."""
|
||||
|
||||
enabled_default: Callable[[Host, int], bool] | None = None
|
||||
method: Callable[[Host, int], Any]
|
||||
supported: Callable[[Host, int, int | None], bool] = lambda api, ch, sub: True
|
||||
enabled_default: Callable[[Host, int, int | None], bool] | None = None
|
||||
method: Callable[[Host, int, int | None], Any]
|
||||
ptz_cmd: str | None = None
|
||||
|
||||
|
||||
@@ -53,113 +54,141 @@ BUTTON_ENTITIES = (
|
||||
ReolinkButtonEntityDescription(
|
||||
key="ptz_stop",
|
||||
translation_key="ptz_stop",
|
||||
enabled_default=lambda api, ch: api.supported(ch, "pan_tilt"),
|
||||
supported=lambda api, ch: api.supported(ch, "ptz_stop"),
|
||||
method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.stop.value),
|
||||
enabled_default=lambda api, ch, sub: api.supported(ch, "pan_tilt", sub),
|
||||
supported=lambda api, ch, sub: api.supported(ch, "ptz_stop", sub),
|
||||
method=lambda api, ch, sub: api.set_ptz_command(
|
||||
ch, sub, command=PtzEnum.stop.value
|
||||
),
|
||||
),
|
||||
ReolinkButtonEntityDescription(
|
||||
key="ptz_left",
|
||||
translation_key="ptz_left",
|
||||
supported=lambda api, ch: api.supported(ch, "pan"),
|
||||
method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.left.value),
|
||||
supported=lambda api, ch, sub: api.supported(ch, "pan", sub),
|
||||
method=lambda api, ch, sub: api.set_ptz_command(
|
||||
ch, sub, command=PtzEnum.left.value
|
||||
),
|
||||
ptz_cmd=PtzEnum.left.value,
|
||||
),
|
||||
ReolinkButtonEntityDescription(
|
||||
key="ptz_right",
|
||||
translation_key="ptz_right",
|
||||
supported=lambda api, ch: api.supported(ch, "pan"),
|
||||
method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.right.value),
|
||||
supported=lambda api, ch, sub: api.supported(ch, "pan", sub),
|
||||
method=lambda api, ch, sub: api.set_ptz_command(
|
||||
ch, sub, command=PtzEnum.right.value
|
||||
),
|
||||
ptz_cmd=PtzEnum.right.value,
|
||||
),
|
||||
ReolinkButtonEntityDescription(
|
||||
key="ptz_up",
|
||||
translation_key="ptz_up",
|
||||
supported=lambda api, ch: api.supported(ch, "tilt"),
|
||||
method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.up.value),
|
||||
supported=lambda api, ch, sub: api.supported(ch, "tilt", sub),
|
||||
method=lambda api, ch, sub: api.set_ptz_command(
|
||||
ch, sub, command=PtzEnum.up.value
|
||||
),
|
||||
ptz_cmd=PtzEnum.up.value,
|
||||
),
|
||||
ReolinkButtonEntityDescription(
|
||||
key="ptz_down",
|
||||
translation_key="ptz_down",
|
||||
supported=lambda api, ch: api.supported(ch, "tilt"),
|
||||
method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.down.value),
|
||||
supported=lambda api, ch, sub: api.supported(ch, "tilt", sub),
|
||||
method=lambda api, ch, sub: api.set_ptz_command(
|
||||
ch, sub, command=PtzEnum.down.value
|
||||
),
|
||||
ptz_cmd=PtzEnum.down.value,
|
||||
),
|
||||
ReolinkButtonEntityDescription(
|
||||
key="ptz_zoom_in",
|
||||
translation_key="ptz_zoom_in",
|
||||
entity_registry_enabled_default=False,
|
||||
supported=lambda api, ch: api.supported(ch, "zoom_basic"),
|
||||
method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.zoomin.value),
|
||||
supported=lambda api, ch, sub: api.supported(ch, "zoom_basic", sub),
|
||||
method=lambda api, ch, sub: api.set_ptz_command(
|
||||
ch, sub, command=PtzEnum.zoomin.value
|
||||
),
|
||||
ptz_cmd=PtzEnum.zoomin.value,
|
||||
),
|
||||
ReolinkButtonEntityDescription(
|
||||
key="ptz_zoom_out",
|
||||
translation_key="ptz_zoom_out",
|
||||
entity_registry_enabled_default=False,
|
||||
supported=lambda api, ch: api.supported(ch, "zoom_basic"),
|
||||
method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.zoomout.value),
|
||||
supported=lambda api, ch, sub: api.supported(ch, "zoom_basic", sub),
|
||||
method=lambda api, ch, sub: api.set_ptz_command(
|
||||
ch, sub, command=PtzEnum.zoomout.value
|
||||
),
|
||||
ptz_cmd=PtzEnum.zoomout.value,
|
||||
),
|
||||
ReolinkButtonEntityDescription(
|
||||
key="ptz_left_up",
|
||||
translation_key="ptz_left_up",
|
||||
entity_registry_enabled_default=False,
|
||||
supported=lambda api, ch: api.supported(ch, "ptz_diagonal"),
|
||||
method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.leftup.value),
|
||||
supported=lambda api, ch, sub: api.supported(ch, "ptz_diagonal", sub),
|
||||
method=lambda api, ch, sub: api.set_ptz_command(
|
||||
ch, sub, command=PtzEnum.leftup.value
|
||||
),
|
||||
ptz_cmd=PtzEnum.leftup.value,
|
||||
),
|
||||
ReolinkButtonEntityDescription(
|
||||
key="ptz_left_down",
|
||||
translation_key="ptz_left_down",
|
||||
entity_registry_enabled_default=False,
|
||||
supported=lambda api, ch: api.supported(ch, "ptz_diagonal"),
|
||||
method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.leftdown.value),
|
||||
supported=lambda api, ch, sub: api.supported(ch, "ptz_diagonal", sub),
|
||||
method=lambda api, ch, sub: api.set_ptz_command(
|
||||
ch, sub, command=PtzEnum.leftdown.value
|
||||
),
|
||||
ptz_cmd=PtzEnum.leftdown.value,
|
||||
),
|
||||
ReolinkButtonEntityDescription(
|
||||
key="ptz_right_up",
|
||||
translation_key="ptz_right_up",
|
||||
entity_registry_enabled_default=False,
|
||||
supported=lambda api, ch: api.supported(ch, "ptz_diagonal"),
|
||||
method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.rightup.value),
|
||||
supported=lambda api, ch, sub: api.supported(ch, "ptz_diagonal", sub),
|
||||
method=lambda api, ch, sub: api.set_ptz_command(
|
||||
ch, sub, command=PtzEnum.rightup.value
|
||||
),
|
||||
ptz_cmd=PtzEnum.rightup.value,
|
||||
),
|
||||
ReolinkButtonEntityDescription(
|
||||
key="ptz_right_down",
|
||||
translation_key="ptz_right_down",
|
||||
entity_registry_enabled_default=False,
|
||||
supported=lambda api, ch: api.supported(ch, "ptz_diagonal"),
|
||||
method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.rightdown.value),
|
||||
supported=lambda api, ch, sub: api.supported(ch, "ptz_diagonal", sub),
|
||||
method=lambda api, ch, sub: api.set_ptz_command(
|
||||
ch, sub, command=PtzEnum.rightdown.value
|
||||
),
|
||||
ptz_cmd=PtzEnum.rightdown.value,
|
||||
),
|
||||
ReolinkButtonEntityDescription(
|
||||
key="ptz_auto",
|
||||
translation_key="ptz_auto",
|
||||
entity_registry_enabled_default=False,
|
||||
supported=lambda api, ch: api.supported(ch, "ptz_auto"),
|
||||
method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.auto.value),
|
||||
supported=lambda api, ch, sub: api.supported(ch, "ptz_auto", sub),
|
||||
method=lambda api, ch, sub: api.set_ptz_command(
|
||||
ch, sub, command=PtzEnum.auto.value
|
||||
),
|
||||
ptz_cmd=PtzEnum.auto.value,
|
||||
),
|
||||
ReolinkButtonEntityDescription(
|
||||
key="ptz_calibrate",
|
||||
translation_key="ptz_calibrate",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
supported=lambda api, ch: api.supported(ch, "ptz_callibrate"),
|
||||
method=lambda api, ch: api.ptz_callibrate(ch),
|
||||
supported=lambda api, ch, sub: api.supported(ch, "ptz_callibrate", sub),
|
||||
method=lambda api, ch, sub: api.ptz_callibrate(ch, sub),
|
||||
),
|
||||
ReolinkButtonEntityDescription(
|
||||
key="guard_go_to",
|
||||
translation_key="guard_go_to",
|
||||
supported=lambda api, ch: api.supported(ch, "ptz_guard"),
|
||||
method=lambda api, ch: api.set_ptz_guard(ch, command=GuardEnum.goto.value),
|
||||
supported=lambda api, ch, sub: api.supported(ch, "ptz_guard", sub),
|
||||
method=lambda api, ch, sub: api.set_ptz_guard(
|
||||
ch, sub, command=GuardEnum.goto.value
|
||||
),
|
||||
),
|
||||
ReolinkButtonEntityDescription(
|
||||
key="guard_set",
|
||||
translation_key="guard_set",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
supported=lambda api, ch: api.supported(ch, "ptz_guard"),
|
||||
method=lambda api, ch: api.set_ptz_guard(ch, command=GuardEnum.set.value),
|
||||
supported=lambda api, ch, sub: api.supported(ch, "ptz_guard", sub),
|
||||
method=lambda api, ch, sub: api.set_ptz_guard(
|
||||
ch, sub, command=GuardEnum.set.value
|
||||
),
|
||||
),
|
||||
ReolinkButtonEntityDescription(
|
||||
key="reboot",
|
||||
@@ -167,14 +196,14 @@ BUTTON_ENTITIES = (
|
||||
device_class=ButtonDeviceClass.RESTART,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_registry_enabled_default=False,
|
||||
supported=lambda api, ch: api.supported(ch, "reboot"),
|
||||
method=lambda api, ch: api.reboot(ch),
|
||||
supported=lambda api, ch, sub: api.supported(ch, "reboot", sub),
|
||||
method=lambda api, ch, sub: api.reboot(ch),
|
||||
),
|
||||
ReolinkButtonEntityDescription(
|
||||
key="pre_siren",
|
||||
translation_key="pre_siren",
|
||||
supported=lambda api, ch: api.supported(ch, "pre_siren"),
|
||||
method=lambda api, ch: api.baichuan.PreAlarm(ch),
|
||||
supported=lambda api, ch, sub: api.supported(ch, "pre_siren", sub),
|
||||
method=lambda api, ch, sub: api.baichuan.PreAlarm(ch),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -208,10 +237,11 @@ async def async_setup_entry(
|
||||
reolink_data: ReolinkData = config_entry.runtime_data
|
||||
|
||||
entities: list[ReolinkButtonEntity | ReolinkHostButtonEntity] = [
|
||||
ReolinkButtonEntity(reolink_data, channel, entity_description)
|
||||
ReolinkButtonEntity(reolink_data, channel, sub_channel, entity_description)
|
||||
for entity_description in BUTTON_ENTITIES
|
||||
for channel in reolink_data.host.api.stream_channels
|
||||
if entity_description.supported(reolink_data.host.api, channel)
|
||||
for sub_channel in reolink_data.host.api.sub_channels(channel)
|
||||
if entity_description.supported(reolink_data.host.api, channel, sub_channel)
|
||||
]
|
||||
entities.extend(
|
||||
ReolinkHostButtonEntity(reolink_data, entity_description)
|
||||
@@ -230,15 +260,18 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity):
|
||||
self,
|
||||
reolink_data: ReolinkData,
|
||||
channel: int,
|
||||
sub_channel: int | None,
|
||||
entity_description: ReolinkButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize Reolink button entity."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(reolink_data, channel)
|
||||
super().__init__(reolink_data, channel, sub_channel)
|
||||
|
||||
if entity_description.enabled_default is not None:
|
||||
self._attr_entity_registry_enabled_default = (
|
||||
entity_description.enabled_default(self._host.api, self._channel)
|
||||
entity_description.enabled_default(
|
||||
self._host.api, self._channel, self._sub_channel
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
@@ -251,13 +284,18 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity):
|
||||
@override
|
||||
async def async_press(self) -> None:
|
||||
"""Execute the button action."""
|
||||
await self.entity_description.method(self._host.api, self._channel)
|
||||
await self.entity_description.method(
|
||||
self._host.api, self._channel, self._sub_channel
|
||||
)
|
||||
|
||||
@raise_translated_error
|
||||
async def async_ptz_move(self, speed: int) -> None:
|
||||
"""PTZ move with speed."""
|
||||
await self._host.api.set_ptz_command(
|
||||
self._channel, command=self.entity_description.ptz_cmd, speed=speed
|
||||
self._channel,
|
||||
self._sub_channel,
|
||||
command=self.entity_description.ptz_cmd,
|
||||
speed=speed,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -174,22 +174,24 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
|
||||
self,
|
||||
reolink_data: ReolinkData,
|
||||
channel: int,
|
||||
sub_channel: int | None = None,
|
||||
coordinator: ReolinkCoordinator | None = None,
|
||||
) -> None:
|
||||
"""Initialize ReolinkChannelCoordinatorEntity."""
|
||||
super().__init__(reolink_data, coordinator)
|
||||
|
||||
self._channel = channel
|
||||
self._sub_channel = sub_channel
|
||||
sub_id = f"_sub{sub_channel:02d}" if sub_channel is not None else ""
|
||||
if self._host.api.is_nvr and self._host.api.supported(channel, "UID"):
|
||||
self._attr_unique_id = (
|
||||
f"{self._host.unique_id}"
|
||||
f"_{self._host.api.camera_uid(channel)}"
|
||||
f"{sub_id}"
|
||||
f"_{self.entity_description.key}"
|
||||
)
|
||||
else:
|
||||
self._attr_unique_id = (
|
||||
f"{self._host.unique_id}_{channel}_{self.entity_description.key}"
|
||||
)
|
||||
self._attr_unique_id = f"{self._host.unique_id}_{channel}{sub_id}_{self.entity_description.key}"
|
||||
|
||||
if self._host.api.is_nvr:
|
||||
if self._host.api.supported(channel, "UID"):
|
||||
@@ -338,7 +340,7 @@ class ReolinkChimeCoordinatorEntity(ReolinkChannelCoordinatorEntity):
|
||||
) -> None:
|
||||
"""Initialize ReolinkChimeCoordinatorEntity for a chime."""
|
||||
assert chime.channel is not None
|
||||
super().__init__(reolink_data, chime.channel, coordinator)
|
||||
super().__init__(reolink_data, chime.channel, coordinator=coordinator)
|
||||
self._chime = chime
|
||||
|
||||
self._attr_unique_id = (
|
||||
|
||||
@@ -292,7 +292,7 @@ class ReolinkUpdateEntity(
|
||||
self, reolink_data, channel, reolink_data.firmware_coordinator
|
||||
)
|
||||
ReolinkChannelCoordinatorEntity.__init__(
|
||||
self, reolink_data, channel, reolink_data.firmware_coordinator
|
||||
self, reolink_data, channel, coordinator=reolink_data.firmware_coordinator
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ def _init_host_mock(host_mock: MagicMock) -> None:
|
||||
host_mock.protocol = "rtsp"
|
||||
host_mock.channels = [0]
|
||||
host_mock.stream_channels = [0]
|
||||
host_mock.sub_channels.return_value = {None}
|
||||
host_mock.num_cameras = 1
|
||||
host_mock.new_devices = False
|
||||
host_mock.sw_version_update_required = False
|
||||
|
||||
@@ -283,7 +283,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <CameraEntityFeature: 2>,
|
||||
'translation_key': 'ptz_down',
|
||||
'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_down',
|
||||
'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_sub01_ptz_down',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
@@ -334,7 +334,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <CameraEntityFeature: 2>,
|
||||
'translation_key': 'ptz_left',
|
||||
'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_left',
|
||||
'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_sub01_ptz_left',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
@@ -487,7 +487,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <CameraEntityFeature: 2>,
|
||||
'translation_key': 'ptz_right',
|
||||
'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_right',
|
||||
'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_sub01_ptz_right',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
@@ -640,7 +640,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'ptz_stop',
|
||||
'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_stop',
|
||||
'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_sub01_ptz_stop',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
@@ -690,7 +690,7 @@
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <CameraEntityFeature: 2>,
|
||||
'translation_key': 'ptz_up',
|
||||
'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_ptz_up',
|
||||
'unique_id': 'ABC1234567D89EFG_DEF7654321D89GHT_sub01_ptz_up',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -27,8 +27,20 @@ async def test_all_entities(
|
||||
snapshot: SnapshotAssertion,
|
||||
config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
reolink_host: MagicMock,
|
||||
) -> None:
|
||||
"""Test all entities."""
|
||||
|
||||
def mock_supported(ch, capability, sub_channel=None):
|
||||
if capability in {"ptz_stop", "pan", "tilt"}:
|
||||
return sub_channel == 1
|
||||
if sub_channel is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
reolink_host.sub_channels.return_value = {None, 1}
|
||||
reolink_host.supported = mock_supported
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.reolink.PLATFORMS",
|
||||
[Platform.BUTTON],
|
||||
@@ -68,12 +80,22 @@ async def test_button(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sub_channel",
|
||||
[
|
||||
None,
|
||||
1,
|
||||
],
|
||||
)
|
||||
async def test_ptz_move_service(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
reolink_host: MagicMock,
|
||||
sub_channel: int | None,
|
||||
) -> None:
|
||||
"""Test ptz_move entity service using PTZ button entity."""
|
||||
reolink_host.sub_channels.return_value = {sub_channel}
|
||||
|
||||
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BUTTON]):
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
@@ -87,7 +109,9 @@ async def test_ptz_move_service(
|
||||
{ATTR_ENTITY_ID: entity_id, ATTR_SPEED: 5},
|
||||
blocking=True,
|
||||
)
|
||||
reolink_host.set_ptz_command.assert_called_with(0, command="Up", speed=5)
|
||||
reolink_host.set_ptz_command.assert_called_with(
|
||||
0, sub_channel, command="Up", speed=5
|
||||
)
|
||||
|
||||
reolink_host.set_ptz_command.side_effect = ReolinkError("Test error")
|
||||
with pytest.raises(HomeAssistantError):
|
||||
|
||||
Reference in New Issue
Block a user