Reduce polling in Husqvarna Automower (#149255)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Thomas55555
2025-08-05 12:56:27 +02:00
committed by GitHub
parent 064a63fe1f
commit 20fdec9e9c
2 changed files with 267 additions and 4 deletions

View File

@@ -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)

View File

@@ -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