mirror of
https://github.com/home-assistant/core.git
synced 2025-07-28 07:37:34 +00:00
Reolink conserve battery (#145452)
This commit is contained in:
parent
3af2746fea
commit
d71ddcf69e
@ -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()
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user