diff --git a/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py b/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py index 93989fc4..d65d6c1d 100644 --- a/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py +++ b/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py @@ -16,7 +16,8 @@ from typing import Dict, Optional from websockets.asyncio.client import connect -from .baseclass import ForecastSolarBaseclass +from websockets.exceptions import WebSocketException +from .baseclass import ForecastSolarBaseclass, ProviderError logger = logging.getLogger(__name__) logger.info('Loading module') @@ -324,7 +325,7 @@ def get_raw_data_from_provider(self, pvinstallation_name: str) -> dict: Dict with entity state including attributes Raises: - RuntimeError: If WebSocket connection or API request fails + ProviderError: If WebSocket connection or API request fails """ try: loop = asyncio.get_event_loop() @@ -332,7 +333,15 @@ def get_raw_data_from_provider(self, pvinstallation_name: str) -> dict: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - return loop.run_until_complete(self._fetch_entity_state_async()) + try: + return loop.run_until_complete(self._fetch_entity_state_async()) + except (OSError, WebSocketException, RuntimeError) as e: + logger.error( + 'HomeAssistant WebSocket request failed for entity %s: %s', + self.entity_id, e, exc_info=True) + raise ProviderError( + f'HomeAssistant WebSocket request failed for entity {self.entity_id}: {e}' + ) from e async def _fetch_entity_state_async(self) -> dict: """Async fetch of entity state from HomeAssistant diff --git a/tests/test_forecast_solar_homeassistant_ml.py b/tests/test_forecast_solar_homeassistant_ml.py index fae3fd19..d850b644 100644 --- a/tests/test_forecast_solar_homeassistant_ml.py +++ b/tests/test_forecast_solar_homeassistant_ml.py @@ -6,11 +6,14 @@ import asyncio import datetime import json +import socket from unittest.mock import AsyncMock, patch import pytest import pytz +from websockets.exceptions import ConnectionClosedError +from src.batcontrol.forecastsolar.baseclass import ProviderError from src.batcontrol.forecastsolar.forecast_homeassistant_ml import ForecastSolarHomeAssistantML @@ -404,6 +407,84 @@ def test_error_handling_on_fetch_failure(self, pv_installations, timezone): provider.get_raw_data_from_provider(pvinstallation_name) +# Tests for network failure handling (regression: previously these bypassed +# ProviderError handling, so refresh_data() could not fall back to cache) + +class TestNetworkFailureHandling: + """Test that WebSocket/connection failures are wrapped as ProviderError + so the baseclass refresh_data() cache fallback keeps working.""" + + def _make_provider(self, pv_installations, timezone): + return ForecastSolarHomeAssistantML( + pvinstallations=pv_installations, + timezone=timezone, + base_url="http://homeassistant.local:8123", + api_token="test_token", + entity_id="sensor.solar_forecast_ml_evcc_solar_prognose", + sensor_unit="Wh" + ) + + def test_dns_failure_is_wrapped_as_provider_error(self, pv_installations, timezone): + """A DNS resolution failure must surface as ProviderError, not raw OSError.""" + provider = self._make_provider(pv_installations, timezone) + + with patch( + 'src.batcontrol.forecastsolar.forecast_homeassistant_ml.connect', + new_callable=AsyncMock, + side_effect=socket.gaierror('Name or service not known'), + ): + with pytest.raises(ProviderError): + provider.get_raw_data_from_provider(pv_installations[0]['name']) + + def test_websocket_error_is_wrapped_as_provider_error(self, pv_installations, timezone): + """A websockets-level failure must surface as ProviderError, not raw exception.""" + provider = self._make_provider(pv_installations, timezone) + + with patch( + 'src.batcontrol.forecastsolar.forecast_homeassistant_ml.connect', + new_callable=AsyncMock, + side_effect=ConnectionClosedError(None, None), + ): + with pytest.raises(ProviderError): + provider.get_raw_data_from_provider(pv_installations[0]['name']) + + def test_auth_failure_is_wrapped_as_provider_error(self, pv_installations, timezone): + """An authentication failure (RuntimeError) must surface as ProviderError.""" + provider = self._make_provider(pv_installations, timezone) + + mock_ws = AsyncMock() + mock_ws.recv = AsyncMock(side_effect=[ + json.dumps({"type": "auth_required", "ha_version": "2026.3.0"}), + json.dumps({"type": "auth_invalid", "message": "Invalid access token"}), + ]) + mock_ws.send = AsyncMock() + mock_ws.close = AsyncMock() + + with patch( + 'src.batcontrol.forecastsolar.forecast_homeassistant_ml.connect', + new_callable=AsyncMock, + return_value=mock_ws, + ): + with pytest.raises(ProviderError): + provider.get_raw_data_from_provider(pv_installations[0]['name']) + + def test_refresh_keeps_cached_data_on_connection_failure( + self, pv_installations, timezone, ha_entity_state): + """A transient connection failure must leave the last good response cached.""" + provider = self._make_provider(pv_installations, timezone) + pvinstallation_name = pv_installations[0]['name'] + provider.store_raw_data(pvinstallation_name, ha_entity_state) + + with patch( + 'src.batcontrol.forecastsolar.forecast_homeassistant_ml.connect', + new_callable=AsyncMock, + side_effect=socket.gaierror('Name or service not known'), + ): + provider.refresh_data() + + assert provider.get_raw_data(pvinstallation_name) == ha_entity_state + + # Tests for edge cases class TestEdgeCases: