diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 91c1e619d0b..9932aaacb65 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -62,6 +62,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): self.new_areas_callbacks: list[Callable[[str, set[int]], None]] = [] self.pong: datetime | None = None self.websocket_alive: bool = False + self.websocket_callbacks: list[Callable[[bool], None]] = [] self._watchdog_task: asyncio.Task | None = None @override @@ -198,12 +199,17 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): ) async def _pong_watchdog(self) -> None: + """Watchdog to check for pong messages.""" _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) + is_alive = await self.api.send_empty_message() + _LOGGER.debug("Ping result: %s", is_alive) + if self.websocket_alive != is_alive: + self.websocket_alive = is_alive + for ws_callback in self.websocket_callbacks: + ws_callback(is_alive) await asyncio.sleep(PING_INTERVAL) _LOGGER.debug("Websocket alive %s", self.websocket_alive) diff --git a/homeassistant/components/husqvarna_automower/event.py b/homeassistant/components/husqvarna_automower/event.py index 8e2e48b940d..7fe8bae8c2d 100644 --- a/homeassistant/components/husqvarna_automower/event.py +++ b/homeassistant/components/husqvarna_automower/event.py @@ -1,6 +1,7 @@ """Creates the event entities for supported mowers.""" from collections.abc import Callable +import logging from aioautomower.model import SingleMessageData @@ -18,6 +19,7 @@ from .const import ERROR_KEYS from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerBaseEntity +_LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 ATTR_SEVERITY = "severity" @@ -80,6 +82,12 @@ class AutomowerMessageEventEntity(AutomowerBaseEntity, EventEntity): """Initialize Automower message event entity.""" super().__init__(mower_id, coordinator) self._attr_unique_id = f"{mower_id}_message" + self.websocket_alive: bool = coordinator.websocket_alive + + @property + def available(self) -> bool: + """Return True if the entity is available.""" + return self.websocket_alive and self.mower_id in self.coordinator.data @callback def _handle(self, msg: SingleMessageData) -> None: @@ -102,7 +110,17 @@ class AutomowerMessageEventEntity(AutomowerBaseEntity, EventEntity): """Register callback when entity is added to hass.""" await super().async_added_to_hass() self.coordinator.api.register_single_message_callback(self._handle) + self.coordinator.websocket_callbacks.append(self._handle_websocket_update) async def async_will_remove_from_hass(self) -> None: """Unregister WebSocket callback when entity is removed.""" self.coordinator.api.unregister_single_message_callback(self._handle) + self.coordinator.websocket_callbacks.remove(self._handle_websocket_update) + + def _handle_websocket_update(self, is_alive: bool) -> None: + """Handle websocket status changes.""" + if self.websocket_alive == is_alive: + return + self.websocket_alive = is_alive + _LOGGER.debug("WebSocket status changed to %s, updating entity state", is_alive) + self.async_write_ha_state() diff --git a/tests/components/husqvarna_automower/conftest.py b/tests/components/husqvarna_automower/conftest.py index 1cd6f9b393e..02b9b2715a1 100644 --- a/tests/components/husqvarna_automower/conftest.py +++ b/tests/components/husqvarna_automower/conftest.py @@ -1,7 +1,7 @@ """Test helpers for Husqvarna Automower.""" import asyncio -from collections.abc import Generator +from collections.abc import Callable, Generator import time from unittest.mock import AsyncMock, create_autospec, patch @@ -16,7 +16,7 @@ from homeassistant.components.application_credentials import ( async_import_client_credential, ) from homeassistant.components.husqvarna_automower.const import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -137,3 +137,21 @@ def mock_automower_client( spec_set=True, ) yield mock_instance + + +@pytest.fixture +def automower_ws_ready(mock_automower_client: AsyncMock) -> list[Callable[[], None]]: + """Fixture to capture ws_ready_callbacks.""" + + ws_ready_callbacks: list[Callable[[], None]] = [] + + @callback + def fake_register_ws_ready_callback(cb: Callable[[], None]) -> None: + ws_ready_callbacks.append(cb) + + mock_automower_client.register_ws_ready_callback.side_effect = ( + fake_register_ws_ready_callback + ) + mock_automower_client.send_empty_message.return_value = True + + return ws_ready_callbacks diff --git a/tests/components/husqvarna_automower/test_event.py b/tests/components/husqvarna_automower/test_event.py index 6cbfa102976..c4121c1cfb8 100644 --- a/tests/components/husqvarna_automower/test_event.py +++ b/tests/components/husqvarna_automower/test_event.py @@ -33,6 +33,7 @@ async def test_event( mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, values: dict[str, MowerAttributes], + automower_ws_ready: list[Callable[[], None]], ) -> None: """Test that a new message arriving over the websocket creates and updates the sensor.""" callbacks: list[Callable[[SingleMessageData], None]] = [] @@ -46,11 +47,17 @@ async def test_event( mock_automower_client.register_single_message_callback.side_effect = ( fake_register_websocket_response ) + mock_automower_client.send_empty_message.return_value = True # Set up integration await setup_integration(hass, mock_config_entry) await hass.async_block_till_done() + # Start the watchdog and let it run once to set websocket_alive=True + for cb in automower_ws_ready: + cb() + await hass.async_block_till_done() + # Ensure callback was registered for the test mower assert mock_automower_client.register_single_message_callback.called @@ -76,6 +83,7 @@ async def test_event( for cb in callbacks: cb(message) await hass.async_block_till_done() + state = hass.states.get("event.test_mower_1_message") assert state is not None assert state.attributes[ATTR_EVENT_TYPE] == "wheel_motor_overloaded_rear_left" @@ -84,6 +92,12 @@ async def test_event( await hass.config_entries.async_reload(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED + + # Start the new watchdog and let it run + for cb in automower_ws_ready: + cb() + await hass.async_block_till_done() + state = hass.states.get("event.test_mower_1_message") assert state is not None assert state.attributes[ATTR_EVENT_TYPE] == "wheel_motor_overloaded_rear_left" @@ -129,6 +143,7 @@ async def test_event( for cb in callbacks: cb(message) await hass.async_block_till_done() + entry = entity_registry.async_get("event.test_mower_1_message") assert entry is not None assert state.attributes[ATTR_EVENT_TYPE] == "alarm_mower_lifted" @@ -154,9 +169,9 @@ async def test_event_snapshot( hass: HomeAssistant, mock_automower_client: AsyncMock, mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + automower_ws_ready: list[Callable[[], None]], ) -> None: """Test that a new message arriving over the websocket updates the sensor.""" with patch( @@ -179,6 +194,11 @@ async def test_event_snapshot( await setup_integration(hass, mock_config_entry) await hass.async_block_till_done() + # Start the watchdog and let it run once + for cb in automower_ws_ready: + cb() + await hass.async_block_till_done() + # Ensure callback was registered for the test mower assert mock_automower_client.register_single_message_callback.called