mirror of
https://github.com/home-assistant/core.git
synced 2025-08-10 05:58:19 +00:00
Reduce polling in Husqvarna Automower (#149255)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
parent
064a63fe1f
commit
20fdec9e9c
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
from typing import override
|
from typing import override
|
||||||
|
|
||||||
@ -14,7 +14,7 @@ from aioautomower.exceptions import (
|
|||||||
HusqvarnaTimeoutError,
|
HusqvarnaTimeoutError,
|
||||||
HusqvarnaWSServerHandshakeError,
|
HusqvarnaWSServerHandshakeError,
|
||||||
)
|
)
|
||||||
from aioautomower.model import MowerDictionary
|
from aioautomower.model import MowerDictionary, MowerStates
|
||||||
from aioautomower.session import AutomowerSession
|
from aioautomower.session import AutomowerSession
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
@ -29,7 +29,9 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
MAX_WS_RECONNECT_TIME = 600
|
MAX_WS_RECONNECT_TIME = 600
|
||||||
SCAN_INTERVAL = timedelta(minutes=8)
|
SCAN_INTERVAL = timedelta(minutes=8)
|
||||||
DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time
|
DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time
|
||||||
|
PONG_TIMEOUT = timedelta(seconds=90)
|
||||||
|
PING_INTERVAL = timedelta(seconds=10)
|
||||||
|
PING_TIMEOUT = timedelta(seconds=5)
|
||||||
type AutomowerConfigEntry = ConfigEntry[AutomowerDataUpdateCoordinator]
|
type AutomowerConfigEntry = ConfigEntry[AutomowerDataUpdateCoordinator]
|
||||||
|
|
||||||
|
|
||||||
@ -58,6 +60,9 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
|||||||
self.new_devices_callbacks: list[Callable[[set[str]], None]] = []
|
self.new_devices_callbacks: list[Callable[[set[str]], None]] = []
|
||||||
self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = []
|
self.new_zones_callbacks: list[Callable[[str, set[str]], None]] = []
|
||||||
self.new_areas_callbacks: list[Callable[[str, set[int]], None]] = []
|
self.new_areas_callbacks: list[Callable[[str, set[int]], None]] = []
|
||||||
|
self.pong: datetime | None = None
|
||||||
|
self.websocket_alive: bool = False
|
||||||
|
self._watchdog_task: asyncio.Task | None = None
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@callback
|
@callback
|
||||||
@ -71,6 +76,18 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
|||||||
await self.api.connect()
|
await self.api.connect()
|
||||||
self.api.register_data_callback(self.handle_websocket_updates)
|
self.api.register_data_callback(self.handle_websocket_updates)
|
||||||
self.ws_connected = True
|
self.ws_connected = True
|
||||||
|
|
||||||
|
def start_watchdog() -> None:
|
||||||
|
if self._watchdog_task is not None and not self._watchdog_task.done():
|
||||||
|
_LOGGER.debug("Cancelling previous watchdog task")
|
||||||
|
self._watchdog_task.cancel()
|
||||||
|
self._watchdog_task = self.config_entry.async_create_background_task(
|
||||||
|
self.hass,
|
||||||
|
self._pong_watchdog(),
|
||||||
|
"websocket_watchdog",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.api.register_ws_ready_callback(start_watchdog)
|
||||||
try:
|
try:
|
||||||
data = await self.api.get_status()
|
data = await self.api.get_status()
|
||||||
except ApiError as err:
|
except ApiError as err:
|
||||||
@ -93,6 +110,19 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
|||||||
mower_data.capabilities.work_areas for mower_data in self.data.values()
|
mower_data.capabilities.work_areas for mower_data in self.data.values()
|
||||||
):
|
):
|
||||||
self._async_add_remove_work_areas()
|
self._async_add_remove_work_areas()
|
||||||
|
if (
|
||||||
|
not self._should_poll()
|
||||||
|
and self.update_interval is not None
|
||||||
|
and self.websocket_alive
|
||||||
|
):
|
||||||
|
_LOGGER.debug("All mowers inactive and websocket alive: stop polling")
|
||||||
|
self.update_interval = None
|
||||||
|
if self.update_interval is None and self._should_poll():
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Polling re-enabled via WebSocket: at least one mower active"
|
||||||
|
)
|
||||||
|
self.update_interval = SCAN_INTERVAL
|
||||||
|
self.hass.async_create_task(self.async_request_refresh())
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def handle_websocket_updates(self, ws_data: MowerDictionary) -> None:
|
def handle_websocket_updates(self, ws_data: MowerDictionary) -> None:
|
||||||
@ -161,6 +191,30 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
|||||||
"reconnect_task",
|
"reconnect_task",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _should_poll(self) -> bool:
|
||||||
|
"""Return True if at least one mower is connected and at least one is not OFF."""
|
||||||
|
return any(mower.metadata.connected for mower in self.data.values()) and any(
|
||||||
|
mower.mower.state != MowerStates.OFF for mower in self.data.values()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _pong_watchdog(self) -> None:
|
||||||
|
_LOGGER.debug("Watchdog started")
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
_LOGGER.debug("Sending ping")
|
||||||
|
self.websocket_alive = await self.api.send_empty_message()
|
||||||
|
_LOGGER.debug("Ping result: %s", self.websocket_alive)
|
||||||
|
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
_LOGGER.debug("Websocket alive %s", self.websocket_alive)
|
||||||
|
if not self.websocket_alive:
|
||||||
|
_LOGGER.debug("No pong received → restart polling")
|
||||||
|
if self.update_interval is None:
|
||||||
|
self.update_interval = SCAN_INTERVAL
|
||||||
|
await self.async_request_refresh()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
_LOGGER.debug("Watchdog cancelled")
|
||||||
|
|
||||||
def _async_add_remove_devices(self) -> None:
|
def _async_add_remove_devices(self) -> None:
|
||||||
"""Add new devices and remove orphaned devices from the registry."""
|
"""Add new devices and remove orphaned devices from the registry."""
|
||||||
current_devices = set(self.data)
|
current_devices = set(self.data)
|
||||||
|
@ -14,7 +14,7 @@ from aioautomower.exceptions import (
|
|||||||
HusqvarnaTimeoutError,
|
HusqvarnaTimeoutError,
|
||||||
HusqvarnaWSServerHandshakeError,
|
HusqvarnaWSServerHandshakeError,
|
||||||
)
|
)
|
||||||
from aioautomower.model import Calendar, MowerAttributes, WorkArea
|
from aioautomower.model import Calendar, MowerAttributes, MowerStates, WorkArea
|
||||||
from freezegun.api import FrozenDateTimeFactory
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
import pytest
|
import pytest
|
||||||
from syrupy.assertion import SnapshotAssertion
|
from syrupy.assertion import SnapshotAssertion
|
||||||
@ -484,3 +484,212 @@ async def test_add_and_remove_work_area(
|
|||||||
- ADDITIONAL_NUMBER_ENTITIES
|
- ADDITIONAL_NUMBER_ENTITIES
|
||||||
- ADDITIONAL_SENSOR_ENTITIES
|
- ADDITIONAL_SENSOR_ENTITIES
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("mower1_connected", "mower1_state", "mower2_connected", "mower2_state"),
|
||||||
|
[
|
||||||
|
(True, MowerStates.OFF, False, MowerStates.OFF), # False
|
||||||
|
(False, MowerStates.PAUSED, False, MowerStates.OFF), # False
|
||||||
|
(False, MowerStates.OFF, True, MowerStates.OFF), # False
|
||||||
|
(False, MowerStates.OFF, False, MowerStates.PAUSED), # False
|
||||||
|
(True, MowerStates.OFF, True, MowerStates.OFF), # False
|
||||||
|
(False, MowerStates.OFF, False, MowerStates.OFF), # False
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_dynamic_polling(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_automower_client,
|
||||||
|
mock_config_entry,
|
||||||
|
freezer: FrozenDateTimeFactory,
|
||||||
|
values: dict[str, MowerAttributes],
|
||||||
|
mower1_connected: bool,
|
||||||
|
mower1_state: MowerStates,
|
||||||
|
mower2_connected: bool,
|
||||||
|
mower2_state: MowerStates,
|
||||||
|
) -> None:
|
||||||
|
"""Test that the ws_ready_callback triggers an attempt to start the Watchdog task.
|
||||||
|
|
||||||
|
and that the pong callback stops polling when all mowers are inactive.
|
||||||
|
"""
|
||||||
|
websocket_values = deepcopy(values)
|
||||||
|
poll_values = deepcopy(values)
|
||||||
|
callback_holder: dict[str, Callable] = {}
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def fake_register_websocket_response(
|
||||||
|
cb: Callable[[dict[str, MowerAttributes]], None],
|
||||||
|
) -> None:
|
||||||
|
callback_holder["data_cb"] = cb
|
||||||
|
|
||||||
|
mock_automower_client.register_data_callback.side_effect = (
|
||||||
|
fake_register_websocket_response
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None:
|
||||||
|
callback_holder["ws_ready_cb"] = cb
|
||||||
|
|
||||||
|
mock_automower_client.register_ws_ready_callback.side_effect = (
|
||||||
|
fake_register_ws_ready_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
|
||||||
|
assert "ws_ready_cb" in callback_holder, "ws_ready_callback was not registered"
|
||||||
|
callback_holder["ws_ready_cb"]()
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_automower_client.get_status.call_count == 1
|
||||||
|
|
||||||
|
freezer.tick(SCAN_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_automower_client.get_status.call_count == 2
|
||||||
|
|
||||||
|
# websocket is still active, but mowers are inactive -> no polling required
|
||||||
|
poll_values[TEST_MOWER_ID].metadata.connected = mower1_connected
|
||||||
|
poll_values[TEST_MOWER_ID].mower.state = mower1_state
|
||||||
|
poll_values["1234"].metadata.connected = mower2_connected
|
||||||
|
poll_values["1234"].mower.state = mower2_state
|
||||||
|
|
||||||
|
mock_automower_client.get_status.return_value = poll_values
|
||||||
|
freezer.tick(SCAN_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_automower_client.get_status.call_count == 3
|
||||||
|
|
||||||
|
freezer.tick(SCAN_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_automower_client.get_status.call_count == 4
|
||||||
|
|
||||||
|
freezer.tick(SCAN_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_automower_client.get_status.call_count == 4
|
||||||
|
|
||||||
|
freezer.tick(SCAN_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_automower_client.get_status.call_count == 4
|
||||||
|
|
||||||
|
# websocket is still active, and mowers are active -> polling required
|
||||||
|
mock_automower_client.get_status.reset_mock()
|
||||||
|
assert mock_automower_client.get_status.call_count == 0
|
||||||
|
poll_values[TEST_MOWER_ID].metadata.connected = True
|
||||||
|
poll_values[TEST_MOWER_ID].mower.state = MowerStates.PAUSED
|
||||||
|
poll_values["1234"].metadata.connected = False
|
||||||
|
poll_values["1234"].mower.state = MowerStates.OFF
|
||||||
|
websocket_values = deepcopy(poll_values)
|
||||||
|
callback_holder["data_cb"](websocket_values)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_automower_client.get_status.call_count == 1
|
||||||
|
|
||||||
|
freezer.tick(SCAN_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_automower_client.get_status.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("mower1_connected", "mower1_state", "mower2_connected", "mower2_state"),
|
||||||
|
[
|
||||||
|
(True, MowerStates.OFF, False, MowerStates.OFF), # False
|
||||||
|
(False, MowerStates.PAUSED, False, MowerStates.OFF), # False
|
||||||
|
(False, MowerStates.OFF, True, MowerStates.OFF), # False
|
||||||
|
(False, MowerStates.OFF, False, MowerStates.PAUSED), # False
|
||||||
|
(True, MowerStates.OFF, True, MowerStates.OFF), # False
|
||||||
|
(False, MowerStates.OFF, False, MowerStates.OFF), # False
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_websocket_watchdog(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_automower_client,
|
||||||
|
mock_config_entry,
|
||||||
|
freezer: FrozenDateTimeFactory,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
|
values: dict[str, MowerAttributes],
|
||||||
|
mower1_connected: bool,
|
||||||
|
mower1_state: MowerStates,
|
||||||
|
mower2_connected: bool,
|
||||||
|
mower2_state: MowerStates,
|
||||||
|
) -> None:
|
||||||
|
"""Test that the ws_ready_callback triggers an attempt to start the Watchdog task.
|
||||||
|
|
||||||
|
and that the pong callback stops polling when all mowers are inactive.
|
||||||
|
"""
|
||||||
|
poll_values = deepcopy(values)
|
||||||
|
callback_holder: dict[str, Callable] = {}
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def fake_register_websocket_response(
|
||||||
|
cb: Callable[[dict[str, MowerAttributes]], None],
|
||||||
|
) -> None:
|
||||||
|
callback_holder["data_cb"] = cb
|
||||||
|
|
||||||
|
mock_automower_client.register_data_callback.side_effect = (
|
||||||
|
fake_register_websocket_response
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None:
|
||||||
|
callback_holder["ws_ready_cb"] = cb
|
||||||
|
|
||||||
|
mock_automower_client.register_ws_ready_callback.side_effect = (
|
||||||
|
fake_register_ws_ready_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
|
||||||
|
assert "ws_ready_cb" in callback_holder, "ws_ready_callback was not registered"
|
||||||
|
callback_holder["ws_ready_cb"]()
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_automower_client.get_status.call_count == 1
|
||||||
|
|
||||||
|
freezer.tick(SCAN_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_automower_client.get_status.call_count == 2
|
||||||
|
|
||||||
|
# websocket is still active, but mowers are inactive -> no polling required
|
||||||
|
poll_values[TEST_MOWER_ID].metadata.connected = mower1_connected
|
||||||
|
poll_values[TEST_MOWER_ID].mower.state = mower1_state
|
||||||
|
poll_values["1234"].metadata.connected = mower2_connected
|
||||||
|
poll_values["1234"].mower.state = mower2_state
|
||||||
|
|
||||||
|
mock_automower_client.get_status.return_value = poll_values
|
||||||
|
freezer.tick(SCAN_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_automower_client.get_status.call_count == 3
|
||||||
|
|
||||||
|
freezer.tick(SCAN_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_automower_client.get_status.call_count == 4
|
||||||
|
|
||||||
|
freezer.tick(SCAN_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_automower_client.get_status.call_count == 4
|
||||||
|
|
||||||
|
# Simulate Pong loss and reset mock -> polling required
|
||||||
|
mock_automower_client.send_empty_message.return_value = False
|
||||||
|
mock_automower_client.get_status.reset_mock()
|
||||||
|
|
||||||
|
freezer.tick(SCAN_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_automower_client.get_status.call_count == 0
|
||||||
|
|
||||||
|
freezer.tick(SCAN_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_automower_client.get_status.call_count == 1
|
||||||
|
|
||||||
|
freezer.tick(SCAN_INTERVAL)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_automower_client.get_status.call_count == 2
|
||||||
|
Loading…
x
Reference in New Issue
Block a user