From 2f7bb9e5d8d4d7a7b7d700b75496691e28786efc Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Fri, 27 Mar 2026 19:25:27 +0100 Subject: [PATCH 1/6] fix(#303): support evcc Solar-Prognose forecast format and handle None unit - Add Format 1 parser in _parse_forecast_from_attributes for the sensor.solar_forecast_ml_evcc_solar_prognose style data: {"forecast": [{"start": ISO_TS, "end": ISO_TS, "value": WH}]} Absolute timestamps are converted to hour offsets relative to current hour. - Handle unit_of_measurement=None in _check_sensor_unit_async: log a warning and assume Wh (factor 1.0) instead of raising ValueError. - Add TestEvccForecastFormat with 7 tests covering the new parser. --- .../forecast_homeassistant_ml.py | 96 +++++++- tests/test_forecast_solar_homeassistant_ml.py | 223 ++++++++++++++++++ 2 files changed, 313 insertions(+), 6 deletions(-) diff --git a/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py b/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py index a2f2e404..71e4d893 100644 --- a/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py +++ b/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py @@ -4,11 +4,14 @@ HomeAssistant Solar Forecast ML integration (HACS). Based on HACS integration: https://zara-toorox.github.io/ -Sensor: sensor.solar_forecast_ml_prognose_nachste_stunde +Supported sensors: + - sensor.solar_forecast_ml_prognose_nachste_stunde (hours_list / hour_N format) + - sensor.solar_forecast_ml_evcc_solar_prognose (forecast list with start/end/value) """ import asyncio +import datetime import json import logging from typing import Dict, Optional @@ -224,6 +227,16 @@ async def _check_sensor_unit_async(self) -> float: "Unit is kWh, will multiply values by 1000 to convert to Wh") return 1000.0 + if unit is None: + logger.warning( + "Entity '%s' has no unit_of_measurement. " + "Assuming values are already in Wh " + "(typical for evcc Solar-Prognose sensor). " + "Set 'sensor_unit: Wh' in config to suppress this warning.", + self.entity_id + ) + return 1.0 + raise ValueError( f"Unsupported unit_of_measurement '{unit}' for entity " f"'{self.entity_id}'. Only 'Wh' and 'kWh' are supported.") @@ -407,10 +420,10 @@ def get_forecast_from_raw_data(self) -> Dict[int, float]: # Parse forecast data from attributes attributes = raw_data.get("attributes", {}) - + try: forecast_dict = self._parse_forecast_from_attributes(attributes) - + if forecast_dict: values = list(forecast_dict.values()) logger.debug( @@ -422,7 +435,8 @@ def get_forecast_from_raw_data(self) -> Dict[int, float]: ) else: logger.error("Parsed empty forecast from attributes") - raise RuntimeError("No solar forecast data available in entity attributes") + raise RuntimeError( + "No solar forecast data available in entity attributes") return forecast_dict @@ -437,8 +451,12 @@ def _parse_forecast_from_attributes( """Parse forecast data from sensor attributes Supports multiple formats: - 1. Primary: hours_list array with {time, kwh} objects - 2. Fallback: hour_1, hour_2, ... attributes with times + 1. Primary: forecast array with {start, end, value} objects (evcc Solar Forecast style) + - Uses absolute timestamps; values are mapped to hour offsets from now + - Typically used with sensor.solar_forecast_ml_evcc_solar_prognose + 2. Secondary: hours_list array with {time, kwh} objects + - Used with sensor.solar_forecast_ml_prognose_nachste_stunde + 3. Fallback: hour_1, hour_2, ... attributes Args: attributes: Sensor attributes dict from HomeAssistant @@ -451,6 +469,72 @@ def _parse_forecast_from_attributes( """ forecast_dict: Dict[int, float] = {} + # Format 1: forecast list with {start, end, value} - evcc Solar Forecast style + forecast_list = attributes.get("forecast") + if forecast_list and isinstance(forecast_list, list): + first = forecast_list[0] if forecast_list else {} + if isinstance(first, dict) and "start" in first and "value" in first: + logger_ha_details.debug( + "Parsing forecast from 'forecast' list (%d entries)", + len(forecast_list) + ) + now = datetime.datetime.now(self.timezone) + current_hour = now.replace(minute=0, second=0, microsecond=0) + + for entry in forecast_list: + if not isinstance(entry, dict): + continue + + start_str = entry.get("start") + value = entry.get("value") + + if start_str is None or value is None: + continue + + try: + entry_start = datetime.datetime.fromisoformat( + start_str) + # If the timestamp is naive, assume it is in the local timezone + if entry_start.tzinfo is None: + entry_start = self.timezone.localize(entry_start) + else: + entry_start = entry_start.astimezone(self.timezone) + + delta = entry_start - current_hour + hour_offset = int(delta.total_seconds() / 3600) + + if hour_offset < 0: + # Past hour - skip + continue + + wh_value = float(value) * self.unit_conversion_factor + forecast_dict[hour_offset] = wh_value + logger_ha_details.debug( + "Offset %d (start=%s): %.2f Wh", + hour_offset, start_str, wh_value + ) + except (ValueError, TypeError, OverflowError) as exc: + logger_ha_details.debug( + "Skipping entry with start=%s: %s", start_str, exc + ) + continue + + if forecast_dict: + values = list(forecast_dict.values()) + logger.debug( + "Parsed %d slots from 'forecast' list: " + "avg=%.1f Wh, min=%.1f Wh, max=%.1f Wh", + len(forecast_dict), + sum(values) / len(values), + min(values), + max(values) + ) + return forecast_dict + + logger_ha_details.warning( + "'forecast' list present but no valid future entries found") + + # Format 2: hours_list (existing primary format) # Try primary format: hours_list hours_list = attributes.get("hours_list") if hours_list and isinstance(hours_list, list) and len(hours_list) > 0: diff --git a/tests/test_forecast_solar_homeassistant_ml.py b/tests/test_forecast_solar_homeassistant_ml.py index 161eb975..a6545d75 100644 --- a/tests/test_forecast_solar_homeassistant_ml.py +++ b/tests/test_forecast_solar_homeassistant_ml.py @@ -3,6 +3,7 @@ Comprehensive test coverage for HomeAssistant Solar Forecast ML integration. """ +import datetime import json from unittest.mock import AsyncMock, patch @@ -544,5 +545,227 @@ def test_large_forecast_values(self, pv_installations, timezone): assert forecast[1] == 50500.0 +# Tests for evcc Solar Forecast format (start/end/value with absolute timestamps) + +class TestEvccForecastFormat: + """Tests for the evcc Solar Forecast sensor format with absolute timestamps. + + The sensor sensor.solar_forecast_ml_evcc_solar_prognose provides data as: + { + "forecast": [ + {"start": "2026-03-21T14:00:00", "end": "2026-03-21T15:00:00", "value": 3613.0}, + ... + ] + } + Values are in Wh (no unit_of_measurement on the sensor). + """ + + def _make_provider_wh(self, pv_installations, timezone): + """Helper: create a provider configured for Wh (evcc sensor)""" + 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 _hour_str(self, tz, offset_hours: int) -> str: + """Return an ISO timestamp string for current-hour + offset_hours in local tz.""" + now = datetime.datetime.now(tz) + hour_start = now.replace(minute=0, second=0, microsecond=0) + target = hour_start + datetime.timedelta(hours=offset_hours) + # Return naive local time (as the sensor provides) + return target.strftime("%Y-%m-%dT%H:%M:%S") + + def test_parse_forecast_list_basic(self, pv_installations, timezone): + """Test parsing evcc-style forecast list - current and future entries mapped correctly""" + provider = self._make_provider_wh(pv_installations, timezone) + + attributes = { + "forecast": [ + {"start": self._hour_str(timezone, 0), "end": self._hour_str(timezone, 1), + "value": 3613.0}, + {"start": self._hour_str(timezone, 1), "end": self._hour_str(timezone, 2), + "value": 1540.0}, + {"start": self._hour_str(timezone, 2), "end": self._hour_str(timezone, 3), + "value": 800.0}, + ] + } + + forecast = provider._parse_forecast_from_attributes(attributes) + + assert forecast[0] == 3613.0 + assert forecast[1] == 1540.0 + assert forecast[2] == 800.0 + + def test_parse_forecast_list_skips_past_entries(self, pv_installations, timezone): + """Test that past entries (before current hour) are skipped""" + provider = self._make_provider_wh(pv_installations, timezone) + + attributes = { + "forecast": [ + # Past entries + {"start": self._hour_str(timezone, -3), "end": self._hour_str(timezone, -2), + "value": 9999.0}, + {"start": self._hour_str(timezone, -1), "end": self._hour_str(timezone, 0), + "value": 8888.0}, + # Current and future + {"start": self._hour_str(timezone, 0), "end": self._hour_str(timezone, 1), + "value": 800.0}, + {"start": self._hour_str(timezone, 1), "end": self._hour_str(timezone, 2), + "value": 200.0}, + ] + } + + forecast = provider._parse_forecast_from_attributes(attributes) + + assert forecast[0] == 800.0 + assert forecast[1] == 200.0 + # Past hours must not appear + assert -3 not in forecast + assert -1 not in forecast + assert len(forecast) == 2 + + def test_parse_forecast_list_multi_day(self, pv_installations, timezone): + """Test that forecast entries 12 and 24 hours ahead map to correct offsets. + + We deliberately avoid 48h offsets here because the test date (March 27) + is 2 days before the DST transition (March 29) in Europe/Berlin, + which would cause wall-clock vs absolute-time discrepancy. + """ + provider = self._make_provider_wh(pv_installations, timezone) + + attributes = { + "forecast": [ + {"start": self._hour_str(timezone, 0), "end": self._hour_str(timezone, 1), + "value": 0.0}, + {"start": self._hour_str(timezone, 12), "end": self._hour_str(timezone, 13), + "value": 5000.0}, + {"start": self._hour_str(timezone, 24), "end": self._hour_str(timezone, 25), + "value": 3000.0}, + ] + } + + forecast = provider._parse_forecast_from_attributes(attributes) + + assert forecast[0] == 0.0 + assert forecast[12] == 5000.0 + assert forecast[24] == 3000.0 + + def test_parse_forecast_list_wh_no_conversion(self, pv_installations, timezone): + """Test that Wh values from forecast list are NOT multiplied (factor=1.0)""" + provider = self._make_provider_wh(pv_installations, timezone) + + attributes = { + "forecast": [ + {"start": self._hour_str(timezone, 0), "end": self._hour_str(timezone, 1), + "value": 7835.0}, + ] + } + + forecast = provider._parse_forecast_from_attributes(attributes) + + # sensor_unit=Wh → unit_conversion_factor=1.0 → no multiplication + assert forecast[0] == 7835.0 + + def test_parse_forecast_list_invalid_entries_skipped(self, pv_installations, timezone): + """Test that entries with invalid start timestamps or missing values are skipped""" + provider = self._make_provider_wh(pv_installations, timezone) + + attributes = { + "forecast": [ + # Valid + {"start": self._hour_str(timezone, 0), "end": self._hour_str(timezone, 1), + "value": 1000.0}, + # Invalid start timestamp + {"start": "not-a-date", + "end": self._hour_str(timezone, 2), "value": 500.0}, + # Valid (no 'end' key is fine) + {"start": self._hour_str(timezone, 2), "value": 2000.0}, + # Missing start → skipped + {"end": self._hour_str(timezone, 3), "value": 300.0}, + # Missing value → skipped + {"start": self._hour_str(timezone, 4), + "end": self._hour_str(timezone, 5)}, + ] + } + + forecast = provider._parse_forecast_from_attributes(attributes) + + assert forecast[0] == 1000.0 # valid entry at offset 0 + assert forecast[2] == 2000.0 # valid entry at offset 2 + assert 4 not in forecast # no value + # no start entries must not appear + assert len([k for k in forecast if k >= 0]) == 2 + + def test_parse_forecast_list_priority_over_hours_list(self, pv_installations, timezone): + """Test that 'forecast' list takes priority over 'hours_list' when both are present""" + provider = self._make_provider_wh(pv_installations, timezone) + + attributes = { + "forecast": [ + {"start": self._hour_str(timezone, 0), "end": self._hour_str(timezone, 1), + "value": 111.0}, + ], + "hours_list": [ + {"time": "10:00", "kwh": 9.0}, + {"time": "11:00", "kwh": 9.0}, + ] + } + + forecast = provider._parse_forecast_from_attributes(attributes) + + # forecast list wins + assert forecast[0] == 111.0 + assert len(forecast) == 1 + + def test_auto_detect_none_unit_defaults_to_wh(self, pv_installations, timezone): + """Test that auto-detecting a sensor with unit_of_measurement=None defaults to Wh""" + provider_state = { + "entity_id": "sensor.solar_forecast_ml_evcc_solar_prognose", + "state": "69 slots", + "attributes": { + "forecast": [], + "friendly_name": "Solar Forecast ML evcc Solar-Prognose" + # no unit_of_measurement key → evaluates to None + } + } + + # Build a provider with explicit Wh unit, then directly test the async + # unit-check by calling _check_sensor_unit_async with a mocked WebSocket. + provider = 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" # start with an explicit unit; we'll override below + ) + + # Patch _websocket_connect and the subsequent recv messages so that the + # async unit-check path returns an entity with no unit_of_measurement. + mock_ws = AsyncMock() + mock_ws.recv = AsyncMock(side_effect=[ + json.dumps({"type": "auth_required", "ha_version": "2026.3.0"}), + json.dumps({"type": "auth_ok", "ha_version": "2026.3.0"}), + json.dumps({"type": "result", "id": 1, "success": True, + "result": [provider_state]}), + ]) + mock_ws.close = AsyncMock() + + with patch( + 'src.batcontrol.forecastsolar.forecast_homeassistant_ml.connect', + new_callable=AsyncMock, + return_value=mock_ws + ): + import asyncio + factor = asyncio.run(provider._check_sensor_unit_async()) + + # unit_of_measurement is None → should default to 1.0 (Wh) with a warning + assert factor == 1.0 + + if __name__ == "__main__": pytest.main([__file__, "-v"]) From 3d0c0fe314159df1efa8f1f3287ba01e26affc2e Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Wed, 1 Apr 2026 19:17:08 +0200 Subject: [PATCH 2/6] fix(#303): address PR review comments - Normalize trailing 'Z' in ISO timestamps before fromisoformat() for Python 3.9/3.10 compatibility - Use floor division (// 3600) for hour_offset so negative deltas between -1h and 0h stay negative and get skipped correctly - Scan all forecast list entries for expected keys instead of only checking the first element (avoids skipping valid data if first entry is malformed) - Cache _base_hour_start in _hour_str() helper to prevent flaky tests near hour boundaries - Fix misleading docstring in test_auto_detect_none_unit_defaults_to_wh --- .../forecast_homeassistant_ml.py | 17 ++++++++++++----- tests/test_forecast_solar_homeassistant_ml.py | 19 +++++++++++++------ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py b/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py index 71e4d893..9f36e322 100644 --- a/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py +++ b/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py @@ -472,8 +472,11 @@ def _parse_forecast_from_attributes( # Format 1: forecast list with {start, end, value} - evcc Solar Forecast style forecast_list = attributes.get("forecast") if forecast_list and isinstance(forecast_list, list): - first = forecast_list[0] if forecast_list else {} - if isinstance(first, dict) and "start" in first and "value" in first: + has_expected_entry = any( + isinstance(entry, dict) and "start" in entry and "value" in entry + for entry in forecast_list + ) + if has_expected_entry: logger_ha_details.debug( "Parsing forecast from 'forecast' list (%d entries)", len(forecast_list) @@ -492,8 +495,11 @@ def _parse_forecast_from_attributes( continue try: - entry_start = datetime.datetime.fromisoformat( - start_str) + # Normalize UTC timestamps with trailing 'Z' for Python 3.9/3.10 + if isinstance(start_str, str) and start_str.endswith("Z"): + start_str = start_str[:-1] + "+00:00" + + entry_start = datetime.datetime.fromisoformat(start_str) # If the timestamp is naive, assume it is in the local timezone if entry_start.tzinfo is None: entry_start = self.timezone.localize(entry_start) @@ -501,7 +507,8 @@ def _parse_forecast_from_attributes( entry_start = entry_start.astimezone(self.timezone) delta = entry_start - current_hour - hour_offset = int(delta.total_seconds() / 3600) + # Use floor division so negative deltas stay negative + hour_offset = int(delta.total_seconds() // 3600) if hour_offset < 0: # Past hour - skip diff --git a/tests/test_forecast_solar_homeassistant_ml.py b/tests/test_forecast_solar_homeassistant_ml.py index a6545d75..d40e5e63 100644 --- a/tests/test_forecast_solar_homeassistant_ml.py +++ b/tests/test_forecast_solar_homeassistant_ml.py @@ -572,10 +572,16 @@ def _make_provider_wh(self, pv_installations, timezone): ) def _hour_str(self, tz, offset_hours: int) -> str: - """Return an ISO timestamp string for current-hour + offset_hours in local tz.""" - now = datetime.datetime.now(tz) - hour_start = now.replace(minute=0, second=0, microsecond=0) - target = hour_start + datetime.timedelta(hours=offset_hours) + """Return an ISO timestamp string for current-hour + offset_hours in local tz. + + Caches a single base-hour per instance to avoid flakiness when tests + run near an hour boundary (successive calls to datetime.now() could + return different hours otherwise). + """ + if not hasattr(self, "_base_hour_start"): + now = datetime.datetime.now(tz) + self._base_hour_start = now.replace(minute=0, second=0, microsecond=0) + target = self._base_hour_start + datetime.timedelta(hours=offset_hours) # Return naive local time (as the sensor provides) return target.strftime("%Y-%m-%dT%H:%M:%S") @@ -733,8 +739,9 @@ def test_auto_detect_none_unit_defaults_to_wh(self, pv_installations, timezone): } } - # Build a provider with explicit Wh unit, then directly test the async - # unit-check by calling _check_sensor_unit_async with a mocked WebSocket. + # Build a provider with explicit Wh unit, then directly exercise + # _check_sensor_unit_async() to verify that a None unit_of_measurement + # is handled gracefully (returns 1.0 with a warning instead of raising). provider = ForecastSolarHomeAssistantML( pvinstallations=pv_installations, timezone=timezone, From 99ccafa528ed70f7e83966d38b730c72f6da0d4d Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Wed, 1 Apr 2026 19:28:46 +0200 Subject: [PATCH 3/6] fix(#303): address second round of PR review comments - Add autouse fixture _freeze_module_datetime that patches datetime.datetime.now() inside the parser to a fixed reference time (2026-04-02T10:00:00), preventing flaky failures when tests run near an hour or DST boundary - Replace _base_hour_start caching in _hour_str() with a class-level _FIXED_LOCAL_HOUR constant; _hour_str() now derives timestamps from that constant directly - Fix test_parse_forecast_list_multi_day docstring: remove stale references to 'March 27' and specific DST dates - Rename test_auto_detect_none_unit_defaults_to_wh to test_check_sensor_unit_async_none_unit_defaults_to_wh and reword docstring to clearly describe what is being tested --- tests/test_forecast_solar_homeassistant_ml.py | 50 ++++++++++++------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/tests/test_forecast_solar_homeassistant_ml.py b/tests/test_forecast_solar_homeassistant_ml.py index d40e5e63..eba868a3 100644 --- a/tests/test_forecast_solar_homeassistant_ml.py +++ b/tests/test_forecast_solar_homeassistant_ml.py @@ -560,6 +560,27 @@ class TestEvccForecastFormat: Values are in Wh (no unit_of_measurement on the sensor). """ + # Fixed local reference hour for all tests — avoids flakiness at hour/DST boundaries. + # April 2, 2026 is safely after the DST transition (March 29) in Europe/Berlin. + _FIXED_LOCAL_HOUR = datetime.datetime(2026, 4, 2, 10, 0, 0) + + @pytest.fixture(autouse=True) + def _freeze_module_datetime(self, timezone): + """Freeze datetime.datetime.now() inside the parser to _FIXED_LOCAL_HOUR. + + This ensures the test-generated timestamps and the parser's own + current_hour computation use the exact same reference instant, + eliminating flakiness when tests run near an hour or DST boundary. + """ + fixed_now = timezone.localize(self._FIXED_LOCAL_HOUR) + with patch( + 'src.batcontrol.forecastsolar.forecast_homeassistant_ml.datetime' + ) as mock_dt: + mock_dt.datetime.now.return_value = fixed_now + # Keep fromisoformat working with the real implementation + mock_dt.datetime.fromisoformat = datetime.datetime.fromisoformat + yield + def _make_provider_wh(self, pv_installations, timezone): """Helper: create a provider configured for Wh (evcc sensor)""" return ForecastSolarHomeAssistantML( @@ -571,18 +592,9 @@ def _make_provider_wh(self, pv_installations, timezone): sensor_unit="Wh" ) - def _hour_str(self, tz, offset_hours: int) -> str: - """Return an ISO timestamp string for current-hour + offset_hours in local tz. - - Caches a single base-hour per instance to avoid flakiness when tests - run near an hour boundary (successive calls to datetime.now() could - return different hours otherwise). - """ - if not hasattr(self, "_base_hour_start"): - now = datetime.datetime.now(tz) - self._base_hour_start = now.replace(minute=0, second=0, microsecond=0) - target = self._base_hour_start + datetime.timedelta(hours=offset_hours) - # Return naive local time (as the sensor provides) + def _hour_str(self, _tz, offset_hours: int) -> str: + """Return a naive ISO timestamp string for _FIXED_LOCAL_HOUR + offset_hours.""" + target = self._FIXED_LOCAL_HOUR + datetime.timedelta(hours=offset_hours) return target.strftime("%Y-%m-%dT%H:%M:%S") def test_parse_forecast_list_basic(self, pv_installations, timezone): @@ -637,9 +649,8 @@ def test_parse_forecast_list_skips_past_entries(self, pv_installations, timezone def test_parse_forecast_list_multi_day(self, pv_installations, timezone): """Test that forecast entries 12 and 24 hours ahead map to correct offsets. - We deliberately avoid 48h offsets here because the test date (March 27) - is 2 days before the DST transition (March 29) in Europe/Berlin, - which would cause wall-clock vs absolute-time discrepancy. + Uses naive local timestamps so wall-clock hour offsets match exactly. + Avoids offsets that span DST transitions to keep offset arithmetic simple. """ provider = self._make_provider_wh(pv_installations, timezone) @@ -727,8 +738,13 @@ def test_parse_forecast_list_priority_over_hours_list(self, pv_installations, ti assert forecast[0] == 111.0 assert len(forecast) == 1 - def test_auto_detect_none_unit_defaults_to_wh(self, pv_installations, timezone): - """Test that auto-detecting a sensor with unit_of_measurement=None defaults to Wh""" + def test_check_sensor_unit_async_none_unit_defaults_to_wh(self, pv_installations, timezone): + """Test that _check_sensor_unit_async() returns 1.0 when unit_of_measurement is None. + + Directly calls the async method with a mocked WebSocket that returns a + sensor state with no unit_of_measurement key, verifying the graceful + fallback to Wh (factor=1.0) instead of raising a ValueError. + """ provider_state = { "entity_id": "sensor.solar_forecast_ml_evcc_solar_prognose", "state": "69 slots", From 6f4fa04924acb68299deb095d3dbe7caf48ca1ea Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Wed, 1 Apr 2026 19:51:43 +0200 Subject: [PATCH 4/6] fix(#303): address PR312 review comments and improve code quality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update ValueError message in _parse_forecast_from_attributes to mention all three supported formats including the new evcc 'forecast' list format, giving actionable guidance to evcc users when parsing fails. - Refactor _parse_forecast_from_attributes into three focused helpers: _parse_forecast_evcc_entry, _parse_forecast_format1, _parse_forecast_hours_list, _parse_forecast_hour_n — resolves R0912/R0914/R0915 pylint warnings; source file now scores 10.00/10. - Add test coverage for previously unexercised paths in TestEvccForecastFormat: * test_parse_forecast_list_utc_z_timestamps: verifies 'Z' normalization for Python 3.9/3.10 fromisoformat compatibility * test_parse_forecast_list_timezone_aware_timestamps: verifies explicit UTC offset handling and correct conversion to local time * test_sensor_unit_auto_init_none_unit_defaults_to_wh: verifies __init__ with sensor_unit='auto' stores the detected conversion factor correctly - Move asyncio import to top-level in test file (fix W0401 pylint warning) All 334 tests pass. --- .../forecast_homeassistant_ml.py | 327 ++++++++++-------- tests/test_forecast_solar_homeassistant_ml.py | 95 ++++- 2 files changed, 286 insertions(+), 136 deletions(-) diff --git a/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py b/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py index 9f36e322..8b9b1755 100644 --- a/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py +++ b/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py @@ -444,183 +444,240 @@ def get_forecast_from_raw_data(self) -> Dict[int, float]: logger.error("Failed to parse forecast from attributes: %s", e) raise RuntimeError(f"Failed to parse forecast: {e}") from e - def _parse_forecast_from_attributes( + def _parse_forecast_evcc_entry( self, - attributes: dict + entry: dict, + current_hour: "datetime.datetime" + ) -> Optional[tuple]: + """Parse a single evcc-style forecast entry dict. + + Args: + entry: Dict with at least 'start' and 'value' keys. + current_hour: Timezone-aware datetime truncated to the current hour. + + Returns: + (hour_offset, wh_value) tuple, or None if the entry should be skipped. + """ + start_str = entry.get("start") + value = entry.get("value") + + if start_str is None or value is None: + return None + + # Normalize UTC 'Z' suffix for Python 3.9/3.10 fromisoformat compatibility + if isinstance(start_str, str) and start_str.endswith("Z"): + start_str = start_str[:-1] + "+00:00" + + entry_start = datetime.datetime.fromisoformat(start_str) + # Naive timestamps are assumed to be in the local timezone + if entry_start.tzinfo is None: + entry_start = self.timezone.localize(entry_start) + else: + entry_start = entry_start.astimezone(self.timezone) + + delta = entry_start - current_hour + # Floor division keeps negative deltas negative (avoids int() truncation pitfall) + hour_offset = int(delta.total_seconds() // 3600) + + if hour_offset < 0: + return None # Past hour — skip + + wh_value = float(value) * self.unit_conversion_factor + return hour_offset, wh_value + + def _parse_forecast_format1( + self, + forecast_list: list ) -> Dict[int, float]: - """Parse forecast data from sensor attributes + """Parse Format 1: evcc 'forecast' list with {start, end, value} entries. - Supports multiple formats: - 1. Primary: forecast array with {start, end, value} objects (evcc Solar Forecast style) - - Uses absolute timestamps; values are mapped to hour offsets from now - - Typically used with sensor.solar_forecast_ml_evcc_solar_prognose - 2. Secondary: hours_list array with {time, kwh} objects - - Used with sensor.solar_forecast_ml_prognose_nachste_stunde - 3. Fallback: hour_1, hour_2, ... attributes + Entries whose 'start' timestamp is before the current hour are skipped. + Timestamps are converted to hour offsets relative to the current hour. Args: - attributes: Sensor attributes dict from HomeAssistant + forecast_list: List of dicts from the 'forecast' attribute. Returns: - Dict mapping hour index (0, 1, 2, ...) to generation in Wh - - Raises: - ValueError: If no valid forecast data found + Dict mapping hour offset (0 = current hour) to generation in Wh. + Empty dict if no future entries are found. """ forecast_dict: Dict[int, float] = {} + now = datetime.datetime.now(self.timezone) + current_hour = now.replace(minute=0, second=0, microsecond=0) - # Format 1: forecast list with {start, end, value} - evcc Solar Forecast style - forecast_list = attributes.get("forecast") - if forecast_list and isinstance(forecast_list, list): - has_expected_entry = any( - isinstance(entry, dict) and "start" in entry and "value" in entry - for entry in forecast_list - ) - if has_expected_entry: + logger_ha_details.debug( + "Parsing forecast from 'forecast' list (%d entries)", len(forecast_list) + ) + + for entry in forecast_list: + if not isinstance(entry, dict): + continue + try: + result = self._parse_forecast_evcc_entry(entry, current_hour) + except (ValueError, TypeError, OverflowError) as exc: logger_ha_details.debug( - "Parsing forecast from 'forecast' list (%d entries)", - len(forecast_list) + "Skipping entry with start=%s: %s", entry.get("start"), exc ) - now = datetime.datetime.now(self.timezone) - current_hour = now.replace(minute=0, second=0, microsecond=0) - - for entry in forecast_list: - if not isinstance(entry, dict): - continue - - start_str = entry.get("start") - value = entry.get("value") - - if start_str is None or value is None: - continue - - try: - # Normalize UTC timestamps with trailing 'Z' for Python 3.9/3.10 - if isinstance(start_str, str) and start_str.endswith("Z"): - start_str = start_str[:-1] + "+00:00" - - entry_start = datetime.datetime.fromisoformat(start_str) - # If the timestamp is naive, assume it is in the local timezone - if entry_start.tzinfo is None: - entry_start = self.timezone.localize(entry_start) - else: - entry_start = entry_start.astimezone(self.timezone) - - delta = entry_start - current_hour - # Use floor division so negative deltas stay negative - hour_offset = int(delta.total_seconds() // 3600) - - if hour_offset < 0: - # Past hour - skip - continue - - wh_value = float(value) * self.unit_conversion_factor - forecast_dict[hour_offset] = wh_value - logger_ha_details.debug( - "Offset %d (start=%s): %.2f Wh", - hour_offset, start_str, wh_value - ) - except (ValueError, TypeError, OverflowError) as exc: - logger_ha_details.debug( - "Skipping entry with start=%s: %s", start_str, exc - ) - continue - - if forecast_dict: - values = list(forecast_dict.values()) - logger.debug( - "Parsed %d slots from 'forecast' list: " - "avg=%.1f Wh, min=%.1f Wh, max=%.1f Wh", - len(forecast_dict), - sum(values) / len(values), - min(values), - max(values) - ) - return forecast_dict - - logger_ha_details.warning( - "'forecast' list present but no valid future entries found") - - # Format 2: hours_list (existing primary format) - # Try primary format: hours_list - hours_list = attributes.get("hours_list") - if hours_list and isinstance(hours_list, list) and len(hours_list) > 0: + continue + if result is None: + continue + hour_offset, wh_value = result + forecast_dict[hour_offset] = wh_value logger_ha_details.debug( - "Parsing forecast from hours_list (%d entries)", len(hours_list)) - - for hour_idx, entry in enumerate(hours_list): - if not isinstance(entry, dict): - logger_ha_details.debug( - "Skipping non-dict entry in hours_list: %s", entry) - continue - - kwh_value = entry.get("kwh") - if kwh_value is None: - logger_ha_details.debug( - "Skipping entry without 'kwh' key: %s", entry) - continue - - try: - kwh_value = float(kwh_value) - # Convert to Wh - wh_value = kwh_value * self.unit_conversion_factor - - forecast_dict[hour_idx] = wh_value - logger_ha_details.debug( - "Hour %d: %.2f kWh -> %.2f Wh", - hour_idx, kwh_value, wh_value - ) - except (ValueError, TypeError) as e: - logger_ha_details.debug( - "Skipping invalid kWh value in hours_list: %s (error: %s)", - kwh_value, e) - continue + "Offset %d (start=%s): %.2f Wh", hour_offset, entry.get("start"), wh_value + ) - if forecast_dict: - return forecast_dict + if forecast_dict: + values = list(forecast_dict.values()) + logger.debug( + "Parsed %d slots from 'forecast' list: avg=%.1f Wh, min=%.1f Wh, max=%.1f Wh", + len(forecast_dict), + sum(values) / len(values), + min(values), + max(values), + ) + else: + logger_ha_details.warning("'forecast' list present but no valid future entries found") + + return forecast_dict + + def _parse_forecast_hours_list(self, hours_list: list) -> Dict[int, float]: + """Parse Format 2: hours_list array with {time, kwh} entries. + + Args: + hours_list: List of dicts from the 'hours_list' attribute. + + Returns: + Dict mapping hour index (0-based) to generation in Wh. + Empty dict if no valid entries found. + """ + forecast_dict: Dict[int, float] = {} + logger_ha_details.debug( + "Parsing forecast from hours_list (%d entries)", len(hours_list)) - logger_ha_details.warning( - "hours_list present but no valid entries parsed") + for hour_idx, entry in enumerate(hours_list): + if not isinstance(entry, dict): + logger_ha_details.debug( + "Skipping non-dict entry in hours_list: %s", entry) + continue + + kwh_value = entry.get("kwh") + if kwh_value is None: + logger_ha_details.debug( + "Skipping entry without 'kwh' key: %s", entry) + continue + + try: + kwh_value = float(kwh_value) + wh_value = kwh_value * self.unit_conversion_factor + forecast_dict[hour_idx] = wh_value + logger_ha_details.debug( + "Hour %d: %.2f kWh -> %.2f Wh", hour_idx, kwh_value, wh_value) + except (ValueError, TypeError) as exc: + logger_ha_details.debug( + "Skipping invalid kWh value in hours_list: %s (error: %s)", + kwh_value, exc) + + if not forecast_dict: + logger_ha_details.warning("hours_list present but no valid entries parsed") + return forecast_dict + + def _parse_forecast_hour_n(self, attributes: dict) -> Dict[int, float]: + """Parse Format 3 (fallback): hour_1, hour_2, ... attributes. - # Fallback: Try hour_1, hour_2, ... format + Args: + attributes: Full sensor attributes dict. + + Returns: + Dict mapping hour index (0-based) to generation in Wh. + """ + forecast_dict: Dict[int, float] = {} logger_ha_details.debug("Trying fallback hour_N attribute format") hour_idx = 1 while True: hour_key = f"hour_{hour_idx}" - hour_time_key = f"hour_{hour_idx}_time" - if hour_key not in attributes: - break # No more hours + break kwh_value = attributes.get(hour_key) if kwh_value is None: - logger_ha_details.debug( - "Skipping missing %s", hour_key) + logger_ha_details.debug("Skipping missing %s", hour_key) hour_idx += 1 continue try: kwh_value = float(kwh_value) - # Convert to Wh wh_value = kwh_value * self.unit_conversion_factor - # hour_idx 1-based in attributes, but 0-based in forecast_dict + # hour_idx is 1-based in attributes, 0-based in forecast_dict + hour_time_key = f"hour_{hour_idx}_time" forecast_dict[hour_idx - 1] = wh_value logger_ha_details.debug( "Hour %d (%s): %.2f kWh -> %.2f Wh", hour_idx - 1, attributes.get(hour_time_key, "?"), - kwh_value, wh_value - ) - except (ValueError, TypeError) as e: + kwh_value, wh_value) + except (ValueError, TypeError) as exc: logger_ha_details.debug( "Skipping invalid kWh value for %s: %s (error: %s)", - hour_key, kwh_value, e) + hour_key, kwh_value, exc) hour_idx += 1 + return forecast_dict + + def _parse_forecast_from_attributes( + self, + attributes: dict + ) -> Dict[int, float]: + """Parse forecast data from sensor attributes + + Supports multiple formats: + 1. Primary: forecast array with {start, end, value} objects (evcc Solar Forecast style) + - Uses absolute timestamps; values are mapped to hour offsets from now + - Typically used with sensor.solar_forecast_ml_evcc_solar_prognose + 2. Secondary: hours_list array with {time, kwh} objects + - Used with sensor.solar_forecast_ml_prognose_nachste_stunde + 3. Fallback: hour_1, hour_2, ... attributes + + Args: + attributes: Sensor attributes dict from HomeAssistant + + Returns: + Dict mapping hour index (0, 1, 2, ...) to generation in Wh + + Raises: + ValueError: If no valid forecast data found + """ + # Format 1: forecast list with {start, end, value} - evcc Solar Forecast style + forecast_list = attributes.get("forecast") + if forecast_list and isinstance(forecast_list, list): + has_expected_entry = any( + isinstance(entry, dict) and "start" in entry and "value" in entry + for entry in forecast_list + ) + if has_expected_entry: + result = self._parse_forecast_format1(forecast_list) + if result: + return result + + # Format 2: hours_list (existing primary format) + hours_list = attributes.get("hours_list") + if hours_list and isinstance(hours_list, list) and len(hours_list) > 0: + result = self._parse_forecast_hours_list(hours_list) + if result: + return result + + # Format 3 (fallback): hour_1, hour_2, ... attributes + forecast_dict = self._parse_forecast_hour_n(attributes) if not forecast_dict: raise ValueError( "Could not parse any forecast data from sensor attributes. " - "Expected 'hours_list' array or 'hour_N' attributes." + "Expected one of: " + "(1) 'forecast' list with {start, value} entries " + "(evcc Solar-Prognose format, e.g. sensor.solar_forecast_ml_evcc_solar_prognose); " + "(2) 'hours_list' array with {kwh} entries; " + "(3) 'hour_N' attributes. " + "If using the evcc format, check that the 'forecast' list contains " + "future entries (past-only entries are skipped)." ) return forecast_dict diff --git a/tests/test_forecast_solar_homeassistant_ml.py b/tests/test_forecast_solar_homeassistant_ml.py index eba868a3..dd743d61 100644 --- a/tests/test_forecast_solar_homeassistant_ml.py +++ b/tests/test_forecast_solar_homeassistant_ml.py @@ -3,6 +3,7 @@ Comprehensive test coverage for HomeAssistant Solar Forecast ML integration. """ +import asyncio import datetime import json from unittest.mock import AsyncMock, patch @@ -738,6 +739,99 @@ def test_parse_forecast_list_priority_over_hours_list(self, pv_installations, ti assert forecast[0] == 111.0 assert len(forecast) == 1 + def test_parse_forecast_list_utc_z_timestamps(self, pv_installations, timezone): + """Test that UTC timestamps ending in 'Z' are parsed correctly on Python 3.9/3.10. + + Verifies the Z-normalization path (replacing 'Z' with '+00:00') so that + fromisoformat() works on all supported Python versions. + + Reference: Europe/Berlin is UTC+2 on 2026-04-02 (after DST spring-forward). + 2026-04-02T08:00:00Z == 2026-04-02T10:00:00+02:00 → offset 0 (current hour). + 2026-04-02T09:00:00Z == 2026-04-02T11:00:00+02:00 → offset 1. + """ + provider = self._make_provider_wh(pv_installations, timezone) + + attributes = { + "forecast": [ + {"start": "2026-04-02T08:00:00Z", "end": "2026-04-02T09:00:00Z", + "value": 4200.0}, + {"start": "2026-04-02T09:00:00Z", "end": "2026-04-02T10:00:00Z", + "value": 5100.0}, + # Past entry (before current hour in UTC) + {"start": "2026-04-02T07:00:00Z", "end": "2026-04-02T08:00:00Z", + "value": 9999.0}, + ] + } + + forecast = provider._parse_forecast_from_attributes(attributes) + + assert forecast[0] == 4200.0 + assert forecast[1] == 5100.0 + assert -1 not in forecast + assert len(forecast) == 2 + + def test_parse_forecast_list_timezone_aware_timestamps(self, pv_installations, timezone): + """Test that timezone-aware ISO timestamps with explicit UTC offset are handled. + + Uses '+02:00' offset (Europe/Berlin summer time) directly in the timestamp. + 2026-04-02T10:00:00+02:00 → offset 0 (current hour). + 2026-04-02T11:00:00+02:00 → offset 1. + 2026-04-02T10:00:00+00:00 → same UTC instant as 12:00 local → offset 2. + """ + provider = self._make_provider_wh(pv_installations, timezone) + + attributes = { + "forecast": [ + # Explicit local +02:00 timestamps + {"start": "2026-04-02T10:00:00+02:00", "end": "2026-04-02T11:00:00+02:00", + "value": 3000.0}, + {"start": "2026-04-02T11:00:00+02:00", "end": "2026-04-02T12:00:00+02:00", + "value": 3500.0}, + # UTC+00:00 → 12:00 local → offset 2 + {"start": "2026-04-02T10:00:00+00:00", "end": "2026-04-02T11:00:00+00:00", + "value": 2800.0}, + # Past entry + {"start": "2026-04-02T09:00:00+02:00", "end": "2026-04-02T10:00:00+02:00", + "value": 9999.0}, + ] + } + + forecast = provider._parse_forecast_from_attributes(attributes) + + assert forecast[0] == 3000.0 + assert forecast[1] == 3500.0 + assert forecast[2] == 2800.0 + assert -1 not in forecast + assert len(forecast) == 3 + + def test_sensor_unit_auto_init_none_unit_defaults_to_wh(self, pv_installations, timezone): + """Test that provider init with sensor_unit='auto' stores factor 1.0 when + auto-detection returns 1.0 (Wh, e.g. unit_of_measurement=None). + + The actual detection logic (None unit → 1.0) is covered in + test_check_sensor_unit_async_none_unit_defaults_to_wh. Here we verify + that __init__ correctly calls _check_sensor_unit() and stores its result + in unit_conversion_factor. We patch _check_sensor_unit at the sync level + to avoid event-loop state leaking from other async tests. + """ + with patch.object( + ForecastSolarHomeAssistantML, + '_check_sensor_unit', + return_value=1.0 + ): + provider = 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="auto" + ) + + # auto-detect returned 1.0 (Wh) → stored correctly + assert provider.unit_conversion_factor == 1.0 + assert provider.sensor_unit == "auto" + def test_check_sensor_unit_async_none_unit_defaults_to_wh(self, pv_installations, timezone): """Test that _check_sensor_unit_async() returns 1.0 when unit_of_measurement is None. @@ -783,7 +877,6 @@ def test_check_sensor_unit_async_none_unit_defaults_to_wh(self, pv_installations new_callable=AsyncMock, return_value=mock_ws ): - import asyncio factor = asyncio.run(provider._check_sensor_unit_async()) # unit_of_measurement is None → should default to 1.0 (Wh) with a warning From 9074a4eed1136ca4513e8ee169480ab4162e82f1 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Wed, 1 Apr 2026 20:03:51 +0200 Subject: [PATCH 5/6] fix(#303): zero-fill sparse forecast gaps in evcc format parser _parse_forecast_format1 now fills missing offsets between 0 and max_offset with 0.0 after parsing. This ensures the resulting dict always has consecutive keys, satisfying the ForecastSolarBaseclass minimum-interval length check (>= 12 intervals) which uses len(). Without this, an evcc sensor that omits intermediate zero-watt entries (e.g. night hours) could produce a sparse dict where len() < 12, causing a spurious 'Less than 12 hours of forecast data' RuntimeError. Updated tests: - test_parse_forecast_list_multi_day: now validates 25 consecutive keys (0..24) with zero-filled gaps between the 3 explicit entries. - test_parse_forecast_list_invalid_entries_skipped: offset 1 is now expected as 0.0 (gap between valid offsets 0 and 2). --- .../forecast_homeassistant_ml.py | 8 ++++++++ tests/test_forecast_solar_homeassistant_ml.py | 20 ++++++++++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py b/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py index 8b9b1755..35b75145 100644 --- a/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py +++ b/src/batcontrol/forecastsolar/forecast_homeassistant_ml.py @@ -528,6 +528,14 @@ def _parse_forecast_format1( ) if forecast_dict: + # Fill missing offsets with 0.0 so the dict has consecutive keys + # 0..max_offset. This ensures the baseclass length validation + # (requires >= 12 consecutive intervals) works correctly. + max_offset = max(forecast_dict.keys()) + for gap in range(max_offset + 1): + if gap not in forecast_dict: + forecast_dict[gap] = 0.0 + values = list(forecast_dict.values()) logger.debug( "Parsed %d slots from 'forecast' list: avg=%.1f Wh, min=%.1f Wh, max=%.1f Wh", diff --git a/tests/test_forecast_solar_homeassistant_ml.py b/tests/test_forecast_solar_homeassistant_ml.py index dd743d61..cbddefb9 100644 --- a/tests/test_forecast_solar_homeassistant_ml.py +++ b/tests/test_forecast_solar_homeassistant_ml.py @@ -650,8 +650,9 @@ def test_parse_forecast_list_skips_past_entries(self, pv_installations, timezone def test_parse_forecast_list_multi_day(self, pv_installations, timezone): """Test that forecast entries 12 and 24 hours ahead map to correct offsets. - Uses naive local timestamps so wall-clock hour offsets match exactly. - Avoids offsets that span DST transitions to keep offset arithmetic simple. + The parser zero-fills gaps between valid entries so the resulting dict has + consecutive keys 0..max_offset. This satisfies the baseclass minimum-length + check (>= 12 intervals) even when the source only provides sparse entries. """ provider = self._make_provider_wh(pv_installations, timezone) @@ -668,10 +669,18 @@ def test_parse_forecast_list_multi_day(self, pv_installations, timezone): forecast = provider._parse_forecast_from_attributes(attributes) + # Explicit values must map to the right offsets assert forecast[0] == 0.0 assert forecast[12] == 5000.0 assert forecast[24] == 3000.0 + # All intermediate offsets must be filled with 0.0 + assert len(forecast) == 25 # consecutive keys 0..24 + for hour in range(25): + assert hour in forecast + if hour not in (0, 12, 24): + assert forecast[hour] == 0.0 + def test_parse_forecast_list_wh_no_conversion(self, pv_installations, timezone): """Test that Wh values from forecast list are NOT multiplied (factor=1.0)""" provider = self._make_provider_wh(pv_installations, timezone) @@ -713,10 +722,11 @@ def test_parse_forecast_list_invalid_entries_skipped(self, pv_installations, tim forecast = provider._parse_forecast_from_attributes(attributes) assert forecast[0] == 1000.0 # valid entry at offset 0 + assert forecast[1] == 0.0 # zero-filled gap between 0 and 2 assert forecast[2] == 2000.0 # valid entry at offset 2 - assert 4 not in forecast # no value - # no start entries must not appear - assert len([k for k in forecast if k >= 0]) == 2 + assert 4 not in forecast # missing value → not in result, beyond max_offset + # offsets 0..2 (max valid offset) are present; offset 4 is never added + assert len([k for k in forecast if k >= 0]) == 3 def test_parse_forecast_list_priority_over_hours_list(self, pv_installations, timezone): """Test that 'forecast' list takes priority over 'hours_list' when both are present""" From e11ff61be5a0e41b93684edfa9c786f2fb065f94 Mon Sep 17 00:00:00 2001 From: Matthias Strubel Date: Wed, 1 Apr 2026 20:06:31 +0200 Subject: [PATCH 6/6] test(#303): add explicit mock_ws.send = AsyncMock() in unit-check test _check_sensor_unit_async() awaits websocket.send(); making send explicit prevents a potential TypeError if the mock's auto-spec ever changes. --- tests/test_forecast_solar_homeassistant_ml.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_forecast_solar_homeassistant_ml.py b/tests/test_forecast_solar_homeassistant_ml.py index cbddefb9..67e8fa69 100644 --- a/tests/test_forecast_solar_homeassistant_ml.py +++ b/tests/test_forecast_solar_homeassistant_ml.py @@ -880,6 +880,7 @@ def test_check_sensor_unit_async_none_unit_defaults_to_wh(self, pv_installations json.dumps({"type": "result", "id": 1, "success": True, "result": [provider_state]}), ]) + mock_ws.send = AsyncMock() mock_ws.close = AsyncMock() with patch(