Fix adding a work area in Husqvarna Automower (#148358)

This commit is contained in:
Thomas55555 2025-07-14 19:04:00 +02:00 committed by GitHub
parent 9e022ad75e
commit f08d1e547f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 86 additions and 35 deletions

View File

@ -60,15 +60,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
self._devices_last_update: set[str] = set() self._devices_last_update: set[str] = set()
self._zones_last_update: dict[str, set[str]] = {} self._zones_last_update: dict[str, set[str]] = {}
self._areas_last_update: dict[str, set[int]] = {} self._areas_last_update: dict[str, set[int]] = {}
self.async_add_listener(self._on_data_update)
def _async_add_remove_devices_and_entities(self, data: MowerDictionary) -> None:
"""Add/remove devices and dynamic entities, when amount of devices changed."""
self._async_add_remove_devices(data)
for mower_id in data:
if data[mower_id].capabilities.stay_out_zones:
self._async_add_remove_stay_out_zones(data)
if data[mower_id].capabilities.work_areas:
self._async_add_remove_work_areas(data)
async def _async_update_data(self) -> MowerDictionary: async def _async_update_data(self) -> MowerDictionary:
"""Subscribe for websocket and poll data from the API.""" """Subscribe for websocket and poll data from the API."""
@ -82,14 +74,38 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
raise UpdateFailed(err) from err raise UpdateFailed(err) from err
except AuthError as err: except AuthError as err:
raise ConfigEntryAuthFailed(err) from err raise ConfigEntryAuthFailed(err) from err
self._async_add_remove_devices_and_entities(data)
return data return data
@callback
def _on_data_update(self) -> None:
"""Handle data updates and process dynamic entity management."""
if self.data is not None:
self._async_add_remove_devices()
for mower_id in self.data:
if self.data[mower_id].capabilities.stay_out_zones:
self._async_add_remove_stay_out_zones()
if self.data[mower_id].capabilities.work_areas:
self._async_add_remove_work_areas()
@callback @callback
def handle_websocket_updates(self, ws_data: MowerDictionary) -> None: def handle_websocket_updates(self, ws_data: MowerDictionary) -> None:
"""Process websocket callbacks and write them to the DataUpdateCoordinator.""" """Process websocket callbacks and write them to the DataUpdateCoordinator."""
self.hass.async_create_task(self._process_websocket_update(ws_data))
async def _process_websocket_update(self, ws_data: MowerDictionary) -> None:
"""Handle incoming websocket update and update coordinator data."""
for data in ws_data.values():
existing_areas = data.work_areas or {}
for task in data.calendar.tasks:
work_area_id = task.work_area_id
if work_area_id is not None and work_area_id not in existing_areas:
_LOGGER.debug(
"New work area %s detected, refreshing data", work_area_id
)
await self.async_request_refresh()
return
self.async_set_updated_data(ws_data) self.async_set_updated_data(ws_data)
self._async_add_remove_devices_and_entities(ws_data)
@callback @callback
def async_set_updated_data(self, data: MowerDictionary) -> None: def async_set_updated_data(self, data: MowerDictionary) -> None:
@ -138,9 +154,9 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
"reconnect_task", "reconnect_task",
) )
def _async_add_remove_devices(self, data: MowerDictionary) -> None: def _async_add_remove_devices(self) -> None:
"""Add new device, remove non-existing device.""" """Add new device, remove non-existing device."""
current_devices = set(data) current_devices = set(self.data)
# Skip update if no changes # Skip update if no changes
if current_devices == self._devices_last_update: if current_devices == self._devices_last_update:
@ -155,7 +171,6 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
# Process new device # Process new device
new_devices = current_devices - self._devices_last_update new_devices = current_devices - self._devices_last_update
if new_devices: if new_devices:
self.data = data
_LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices))) _LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices)))
self._add_new_devices(new_devices) self._add_new_devices(new_devices)
@ -179,11 +194,11 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
for mower_callback in self.new_devices_callbacks: for mower_callback in self.new_devices_callbacks:
mower_callback(new_devices) mower_callback(new_devices)
def _async_add_remove_stay_out_zones(self, data: MowerDictionary) -> None: def _async_add_remove_stay_out_zones(self) -> None:
"""Add new stay-out zones, remove non-existing stay-out zones.""" """Add new stay-out zones, remove non-existing stay-out zones."""
current_zones = { current_zones = {
mower_id: set(mower_data.stay_out_zones.zones) mower_id: set(mower_data.stay_out_zones.zones)
for mower_id, mower_data in data.items() for mower_id, mower_data in self.data.items()
if mower_data.capabilities.stay_out_zones if mower_data.capabilities.stay_out_zones
and mower_data.stay_out_zones is not None and mower_data.stay_out_zones is not None
} }
@ -225,11 +240,11 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
return current_zones return current_zones
def _async_add_remove_work_areas(self, data: MowerDictionary) -> None: def _async_add_remove_work_areas(self) -> None:
"""Add new work areas, remove non-existing work areas.""" """Add new work areas, remove non-existing work areas."""
current_areas = { current_areas = {
mower_id: set(mower_data.work_areas) mower_id: set(mower_data.work_areas)
for mower_id, mower_data in data.items() for mower_id, mower_data in self.data.items()
if mower_data.capabilities.work_areas and mower_data.work_areas is not None if mower_data.capabilities.work_areas and mower_data.work_areas is not None
} }

View File

@ -3,7 +3,7 @@
from asyncio import Event from asyncio import Event
from collections.abc import Callable from collections.abc import Callable
from copy import deepcopy from copy import deepcopy
from datetime import datetime, timedelta from datetime import datetime, time as dt_time, timedelta
import http import http
import time import time
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@ -14,7 +14,7 @@ from aioautomower.exceptions import (
HusqvarnaTimeoutError, HusqvarnaTimeoutError,
HusqvarnaWSServerHandshakeError, HusqvarnaWSServerHandshakeError,
) )
from aioautomower.model import MowerAttributes, WorkArea from aioautomower.model import Calendar, MowerAttributes, 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
@ -384,14 +384,45 @@ async def test_add_and_remove_work_area(
values: dict[str, MowerAttributes], values: dict[str, MowerAttributes],
) -> None: ) -> None:
"""Test adding a work area in runtime.""" """Test adding a work area in runtime."""
websocket_values = deepcopy(values)
callback_holder: dict[str, Callable] = {}
@callback
def fake_register_websocket_response(
cb: Callable[[dict[str, MowerAttributes]], None],
) -> None:
callback_holder["cb"] = cb
mock_automower_client.register_data_callback.side_effect = (
fake_register_websocket_response
)
await setup_integration(hass, mock_config_entry) await setup_integration(hass, mock_config_entry)
entry = hass.config_entries.async_entries(DOMAIN)[0] entry = hass.config_entries.async_entries(DOMAIN)[0]
current_entites_start = len( current_entites_start = len(
er.async_entries_for_config_entry(entity_registry, entry.entry_id) er.async_entries_for_config_entry(entity_registry, entry.entry_id)
) )
values[TEST_MOWER_ID].work_area_names.append("new work area") await hass.async_block_till_done()
values[TEST_MOWER_ID].work_area_dict.update({1: "new work area"})
values[TEST_MOWER_ID].work_areas.update( assert mock_automower_client.register_data_callback.called
assert "cb" in callback_holder
new_task = Calendar(
start=dt_time(hour=11),
duration=timedelta(60),
monday=True,
tuesday=True,
wednesday=True,
thursday=True,
friday=True,
saturday=True,
sunday=True,
work_area_id=1,
)
websocket_values[TEST_MOWER_ID].calendar.tasks.append(new_task)
poll_values = deepcopy(websocket_values)
poll_values[TEST_MOWER_ID].work_area_names.append("new work area")
poll_values[TEST_MOWER_ID].work_area_dict.update({1: "new work area"})
poll_values[TEST_MOWER_ID].work_areas.update(
{ {
1: WorkArea( 1: WorkArea(
name="new work area", name="new work area",
@ -404,10 +435,15 @@ async def test_add_and_remove_work_area(
) )
} }
) )
mock_automower_client.get_status.return_value = values mock_automower_client.get_status.return_value = poll_values
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass) callback_holder["cb"](websocket_values)
await hass.async_block_till_done() await hass.async_block_till_done()
assert mock_automower_client.get_status.called
state = hass.states.get("sensor.test_mower_1_new_work_area_progress")
assert state is not None
assert state.state == "12"
current_entites_after_addition = len( current_entites_after_addition = len(
er.async_entries_for_config_entry(entity_registry, entry.entry_id) er.async_entries_for_config_entry(entity_registry, entry.entry_id)
) )
@ -419,15 +455,15 @@ async def test_add_and_remove_work_area(
+ ADDITIONAL_SWITCH_ENTITIES + ADDITIONAL_SWITCH_ENTITIES
) )
values[TEST_MOWER_ID].work_area_names.remove("new work area") poll_values[TEST_MOWER_ID].work_area_names.remove("new work area")
del values[TEST_MOWER_ID].work_area_dict[1] del poll_values[TEST_MOWER_ID].work_area_dict[1]
del values[TEST_MOWER_ID].work_areas[1] del poll_values[TEST_MOWER_ID].work_areas[1]
values[TEST_MOWER_ID].work_area_names.remove("Front lawn") poll_values[TEST_MOWER_ID].work_area_names.remove("Front lawn")
del values[TEST_MOWER_ID].work_area_dict[123456] del poll_values[TEST_MOWER_ID].work_area_dict[123456]
del values[TEST_MOWER_ID].work_areas[123456] del poll_values[TEST_MOWER_ID].work_areas[123456]
del values[TEST_MOWER_ID].calendar.tasks[:2] del poll_values[TEST_MOWER_ID].calendar.tasks[:2]
values[TEST_MOWER_ID].mower.work_area_id = 654321 poll_values[TEST_MOWER_ID].mower.work_area_id = 654321
mock_automower_client.get_status.return_value = values mock_automower_client.get_status.return_value = poll_values
freezer.tick(SCAN_INTERVAL) freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass) async_fire_time_changed(hass)
await hass.async_block_till_done() await hass.async_block_till_done()