Reolink conserve battery (#145452)

This commit is contained in:
starkillerOG 2025-06-10 18:05:55 +02:00 committed by GitHub
parent 3af2746fea
commit d71ddcf69e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 178 additions and 30 deletions

View File

@ -3,8 +3,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable
from datetime import timedelta from datetime import timedelta
import logging import logging
from time import time
from typing import Any from typing import Any
from reolink_aio.api import RETRY_ATTEMPTS from reolink_aio.api import RETRY_ATTEMPTS
@ -28,7 +30,13 @@ from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .const import (
BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
CONF_BC_PORT,
CONF_SUPPORTS_PRIVACY_MODE,
CONF_USE_HTTPS,
DOMAIN,
)
from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin
from .host import ReolinkHost from .host import ReolinkHost
from .services import async_setup_services from .services import async_setup_services
@ -220,22 +228,7 @@ async def async_setup_entry(
hass.http.register_view(PlaybackProxyView(hass)) hass.http.register_view(PlaybackProxyView(hass))
async def refresh(*args: Any) -> None: await register_callbacks(host, device_coordinator, hass)
"""Request refresh of coordinator."""
await device_coordinator.async_request_refresh()
host.cancel_refresh_privacy_mode = None
def async_privacy_mode_change() -> None:
"""Request update when privacy mode is turned off."""
if host.privacy_mode and not host.api.baichuan.privacy_mode():
# The privacy mode just turned off, give the API 2 seconds to start
if host.cancel_refresh_privacy_mode is None:
host.cancel_refresh_privacy_mode = async_call_later(hass, 2, refresh)
host.privacy_mode = host.api.baichuan.privacy_mode()
host.api.baichuan.register_callback(
"privacy_mode_change", async_privacy_mode_change, 623
)
# ensure host device is setup before connected camera devices that use via_device # ensure host device is setup before connected camera devices that use via_device
device_registry = dr.async_get(hass) device_registry = dr.async_get(hass)
@ -254,6 +247,51 @@ async def async_setup_entry(
return True return True
async def register_callbacks(
host: ReolinkHost,
device_coordinator: DataUpdateCoordinator[None],
hass: HomeAssistant,
) -> None:
"""Register update callbacks."""
async def refresh(*args: Any) -> None:
"""Request refresh of coordinator."""
await device_coordinator.async_request_refresh()
host.cancel_refresh_privacy_mode = None
def async_privacy_mode_change() -> None:
"""Request update when privacy mode is turned off."""
if host.privacy_mode and not host.api.baichuan.privacy_mode():
# The privacy mode just turned off, give the API 2 seconds to start
if host.cancel_refresh_privacy_mode is None:
host.cancel_refresh_privacy_mode = async_call_later(hass, 2, refresh)
host.privacy_mode = host.api.baichuan.privacy_mode()
def generate_async_camera_wake(channel: int) -> Callable[[], None]:
def async_camera_wake() -> None:
"""Request update when a battery camera wakes up."""
if (
not host.api.sleeping(channel)
and time() - host.last_wake[channel]
> BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL
):
hass.loop.create_task(device_coordinator.async_request_refresh())
return async_camera_wake
host.api.baichuan.register_callback(
"privacy_mode_change", async_privacy_mode_change, 623
)
for channel in host.api.channels:
if host.api.supported(channel, "battery"):
host.api.baichuan.register_callback(
f"camera_{channel}_wake",
generate_async_camera_wake(channel),
145,
channel,
)
async def entry_update_listener( async def entry_update_listener(
hass: HomeAssistant, config_entry: ReolinkConfigEntry hass: HomeAssistant, config_entry: ReolinkConfigEntry
) -> None: ) -> None:
@ -270,6 +308,9 @@ async def async_unload_entry(
await host.stop() await host.stop()
host.api.baichuan.unregister_callback("privacy_mode_change") host.api.baichuan.unregister_callback("privacy_mode_change")
for channel in host.api.channels:
if host.api.supported(channel, "battery"):
host.api.baichuan.unregister_callback(f"camera_{channel}_wake")
if host.cancel_refresh_privacy_mode is not None: if host.cancel_refresh_privacy_mode is not None:
host.cancel_refresh_privacy_mode() host.cancel_refresh_privacy_mode()

View File

@ -5,3 +5,9 @@ DOMAIN = "reolink"
CONF_USE_HTTPS = "use_https" CONF_USE_HTTPS = "use_https"
CONF_BC_PORT = "baichuan_port" CONF_BC_PORT = "baichuan_port"
CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported"
# Conserve battery by not waking the battery cameras each minute during normal update
# Most props are cached in the Home Hub and updated, but some are skipped
BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL = 3600 # seconds
BATTERY_WAKE_UPDATE_INTERVAL = 6 * BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL
BATTERY_ALL_WAKE_UPDATE_INTERVAL = 2 * BATTERY_WAKE_UPDATE_INTERVAL

View File

@ -142,7 +142,9 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
async def async_update(self) -> None: async def async_update(self) -> None:
"""Force full update from the generic entity update service.""" """Force full update from the generic entity update service."""
self._host.last_wake = 0 for channel in self._host.api.channels:
if self._host.api.supported(channel, "battery"):
self._host.last_wake[channel] = 0
await super().async_update() await super().async_update()

View File

@ -34,7 +34,15 @@ from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
from homeassistant.util.ssl import SSLCipherList from homeassistant.util.ssl import SSLCipherList
from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN from .const import (
BATTERY_ALL_WAKE_UPDATE_INTERVAL,
BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
BATTERY_WAKE_UPDATE_INTERVAL,
CONF_BC_PORT,
CONF_SUPPORTS_PRIVACY_MODE,
CONF_USE_HTTPS,
DOMAIN,
)
from .exceptions import ( from .exceptions import (
PasswordIncompatible, PasswordIncompatible,
ReolinkSetupException, ReolinkSetupException,
@ -52,10 +60,6 @@ POLL_INTERVAL_NO_PUSH = 5
LONG_POLL_COOLDOWN = 0.75 LONG_POLL_COOLDOWN = 0.75
LONG_POLL_ERROR_COOLDOWN = 30 LONG_POLL_ERROR_COOLDOWN = 30
# Conserve battery by not waking the battery cameras each minute during normal update
# Most props are cached in the Home Hub and updated, but some are skipped
BATTERY_WAKE_UPDATE_INTERVAL = 3600 # seconds
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -95,7 +99,8 @@ class ReolinkHost:
bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT), bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT),
) )
self.last_wake: float = 0 self.last_wake: defaultdict[int, float] = defaultdict(float)
self.last_all_wake: float = 0
self.update_cmd: defaultdict[str, defaultdict[int | None, int]] = defaultdict( self.update_cmd: defaultdict[str, defaultdict[int | None, int]] = defaultdict(
lambda: defaultdict(int) lambda: defaultdict(int)
) )
@ -459,15 +464,34 @@ class ReolinkHost:
async def update_states(self) -> None: async def update_states(self) -> None:
"""Call the API of the camera device to update the internal states.""" """Call the API of the camera device to update the internal states."""
wake = False wake: dict[int, bool] = {}
if time() - self.last_wake > BATTERY_WAKE_UPDATE_INTERVAL: now = time()
for channel in self._api.stream_channels:
# wake the battery cameras for a complete update # wake the battery cameras for a complete update
wake = True if not self._api.supported(channel, "battery"):
self.last_wake = time() wake[channel] = True
elif (
(
not self._api.sleeping(channel)
and now - self.last_wake[channel]
> BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL
)
or (now - self.last_wake[channel] > BATTERY_WAKE_UPDATE_INTERVAL)
or (now - self.last_all_wake > BATTERY_ALL_WAKE_UPDATE_INTERVAL)
):
# let a waking update coincide with the camera waking up by itself unless it did not wake for BATTERY_WAKE_UPDATE_INTERVAL
wake[channel] = True
self.last_wake[channel] = now
else:
wake[channel] = False
for channel in self._api.channels: # check privacy mode if enabled
if self._api.baichuan.privacy_mode(channel): if self._api.baichuan.privacy_mode(channel):
await self._api.baichuan.get_privacy_mode(channel) await self._api.baichuan.get_privacy_mode(channel)
if all(wake.values()):
self.last_all_wake = now
if self._api.baichuan.privacy_mode(): if self._api.baichuan.privacy_mode():
return # API is shutdown, no need to check states return # API is shutdown, no need to check states

View File

@ -19,7 +19,12 @@ from homeassistant.components.reolink import (
FIRMWARE_UPDATE_INTERVAL, FIRMWARE_UPDATE_INTERVAL,
NUM_CRED_ERRORS, NUM_CRED_ERRORS,
) )
from homeassistant.components.reolink.const import CONF_BC_PORT, DOMAIN from homeassistant.components.reolink.const import (
BATTERY_ALL_WAKE_UPDATE_INTERVAL,
BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
CONF_BC_PORT,
DOMAIN,
)
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ( from homeassistant.const import (
CONF_HOST, CONF_HOST,
@ -1111,6 +1116,76 @@ async def test_privacy_mode_change_callback(
assert config_entry.state is ConfigEntryState.NOT_LOADED assert config_entry.state is ConfigEntryState.NOT_LOADED
async def test_camera_wake_callback(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
config_entry: MockConfigEntry,
reolink_connect: MagicMock,
) -> None:
"""Test camera wake callback."""
class callback_mock_class:
callback_func = None
def register_callback(
self, callback_id: str, callback: Callable[[], None], *args, **key_args
) -> None:
if callback_id == "camera_0_wake":
self.callback_func = callback
callback_mock = callback_mock_class()
reolink_connect.model = TEST_HOST_MODEL
reolink_connect.baichuan.events_active = True
reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True)
reolink_connect.baichuan.register_callback = callback_mock.register_callback
reolink_connect.sleeping.return_value = True
reolink_connect.audio_record.return_value = True
reolink_connect.get_states = AsyncMock()
with (
patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]),
patch(
"homeassistant.components.reolink.host.time",
return_value=BATTERY_ALL_WAKE_UPDATE_INTERVAL,
),
):
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.SWITCH}.{TEST_NVR_NAME}_record_audio"
assert hass.states.get(entity_id).state == STATE_ON
reolink_connect.sleeping.return_value = False
reolink_connect.get_states.reset_mock()
assert reolink_connect.get_states.call_count == 0
# simulate a TCP push callback signaling the battery camera woke up
reolink_connect.audio_record.return_value = False
assert callback_mock.callback_func is not None
with (
patch(
"homeassistant.components.reolink.host.time",
return_value=BATTERY_ALL_WAKE_UPDATE_INTERVAL
+ BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL
+ 5,
),
patch(
"homeassistant.components.reolink.time",
return_value=BATTERY_ALL_WAKE_UPDATE_INTERVAL
+ BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL
+ 5,
),
):
callback_mock.callback_func()
await hass.async_block_till_done()
# check that a coordinator update was scheduled.
assert reolink_connect.get_states.call_count >= 1
assert hass.states.get(entity_id).state == STATE_OFF
async def test_remove( async def test_remove(
hass: HomeAssistant, hass: HomeAssistant,
reolink_connect: MagicMock, reolink_connect: MagicMock,