Add Reolink sub channel support (#181570)

This commit is contained in:
starkillerOG
2026-09-08 17:30:21 +02:00
committed by GitHub
parent 5bf653785c
commit 2b385bb0d7
6 changed files with 121 additions and 56 deletions
+83 -45
View File
@@ -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,
)
+6 -4
View File
@@ -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 = (
+1 -1
View File
@@ -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
)
+1
View File
@@ -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,
})
# ---
+25 -1
View File
@@ -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):