Reolink conserve battery (#145452)

This commit is contained in:
starkillerOG 2025-06-10 18:05:55 +02:00 committed by Franck Nijhof
parent 6c098c3e0a
commit 78d2bf736c
No known key found for this signature in database
GPG Key ID: AB33ADACE7101952
5 changed files with 170 additions and 22 deletions

View File

@ -3,8 +3,10 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from datetime import timedelta
import logging
from time import time
from typing import Any
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.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 .host import ReolinkHost
from .services import async_setup_services
@ -220,6 +228,24 @@ async def async_setup_entry(
hass.http.register_view(PlaybackProxyView(hass))
await register_callbacks(host, device_coordinator, hass)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
config_entry.async_on_unload(
config_entry.add_update_listener(entry_update_listener)
)
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()
@ -233,18 +259,30 @@ async def async_setup_entry(
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
)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
config_entry.async_on_unload(
config_entry.add_update_listener(entry_update_listener)
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,
)
return True
async def entry_update_listener(
hass: HomeAssistant, config_entry: ReolinkConfigEntry
@ -262,6 +300,9 @@ async def async_unload_entry(
await host.stop()
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:
host.cancel_refresh_privacy_mode()

View File

@ -5,3 +5,9 @@ DOMAIN = "reolink"
CONF_USE_HTTPS = "use_https"
CONF_BC_PORT = "baichuan_port"
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:
"""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()

View File

@ -34,7 +34,15 @@ from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.storage import Store
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 (
PasswordIncompatible,
ReolinkSetupException,
@ -52,10 +60,6 @@ POLL_INTERVAL_NO_PUSH = 5
LONG_POLL_COOLDOWN = 0.75
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__)
@ -95,7 +99,8 @@ class ReolinkHost:
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(
lambda: defaultdict(int)
)
@ -459,15 +464,34 @@ class ReolinkHost:
async def update_states(self) -> None:
"""Call the API of the camera device to update the internal states."""
wake = False
if time() - self.last_wake > BATTERY_WAKE_UPDATE_INTERVAL:
wake: dict[int, bool] = {}
now = time()
for channel in self._api.stream_channels:
# wake the battery cameras for a complete update
wake = True
self.last_wake = time()
if not self._api.supported(channel, "battery"):
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):
await self._api.baichuan.get_privacy_mode(channel)
if all(wake.values()):
self.last_all_wake = now
if self._api.baichuan.privacy_mode():
return # API is shutdown, no need to check states

View File

@ -19,7 +19,12 @@ from homeassistant.components.reolink import (
FIRMWARE_UPDATE_INTERVAL,
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.const import (
CONF_HOST,
@ -1111,6 +1116,76 @@ async def test_privacy_mode_change_callback(
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(
hass: HomeAssistant,
reolink_connect: MagicMock,