From 7cf007c512949760b69ace3353881128dd6463a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 05:30:37 +0000 Subject: [PATCH 1/2] forecastsolar: wrap HA-ML WebSocket failures as ProviderError ForecastSolarHomeAssistantML.get_raw_data_from_provider() let DNS, connection, and auth failures from the WebSocket client escape unwrapped. ForecastSolarBaseclass.refresh_data() only catches (ConnectionError, TimeoutError, ProviderError), so these exceptions bypassed the cache fallback entirely (same class of bug as #408 in FCSolar, but for the websockets-based HA-ML provider). Wrap OSError, WebSocketException, and RuntimeError from the fetch as ProviderError so cached forecast data remains usable until its TTL expires. --- .../forecast_homeassistant_ml.py | 14 +++- tests/test_forecast_solar_homeassistant_ml.py | 81 +++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py b/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py index 93989fc4..2b758269 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,14 @@ 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): + logger.error( + 'HomeAssistant WebSocket request failed for entity %s', self.entity_id) + raise ProviderError( + f'HomeAssistant WebSocket request failed for entity {self.entity_id}' + ) from None 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: From 2741dca6cd219f4b1a62bcc736fb01cd1d1494ea Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 19:01:12 +0000 Subject: [PATCH 2/2] forecastsolar: preserve exception details when wrapping HA-ML failures Chain ProviderError from the original exception and log with a stack trace instead of discarding it via 'from None'. Unlike the fcsolar.py case this wraps, none of OSError/WebSocketException/RuntimeError here can carry the API token (it is sent as a post-connect JSON payload, never part of the URL or handshake), so there is no leak risk in keeping the original error for diagnostics. Addresses review feedback on PR #414. --- .../forecastsolar/forecast_homeassistant_ml.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py b/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py index 2b758269..d65d6c1d 100644 --- a/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py +++ b/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py @@ -335,12 +335,13 @@ def get_raw_data_from_provider(self, pvinstallation_name: str) -> dict: try: return loop.run_until_complete(self._fetch_entity_state_async()) - except (OSError, WebSocketException, RuntimeError): + except (OSError, WebSocketException, RuntimeError) as e: logger.error( - 'HomeAssistant WebSocket request failed for entity %s', self.entity_id) + '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}' - ) from None + 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