|
3 | 3 | Comprehensive test coverage for HomeAssistant Solar Forecast ML integration. |
4 | 4 | """ |
5 | 5 |
|
| 6 | +import datetime |
6 | 7 | import json |
7 | 8 | from unittest.mock import AsyncMock, patch |
8 | 9 |
|
@@ -544,5 +545,227 @@ def test_large_forecast_values(self, pv_installations, timezone): |
544 | 545 | assert forecast[1] == 50500.0 |
545 | 546 |
|
546 | 547 |
|
| 548 | +# Tests for evcc Solar Forecast format (start/end/value with absolute timestamps) |
| 549 | + |
| 550 | +class TestEvccForecastFormat: |
| 551 | + """Tests for the evcc Solar Forecast sensor format with absolute timestamps. |
| 552 | +
|
| 553 | + The sensor sensor.solar_forecast_ml_evcc_solar_prognose provides data as: |
| 554 | + { |
| 555 | + "forecast": [ |
| 556 | + {"start": "2026-03-21T14:00:00", "end": "2026-03-21T15:00:00", "value": 3613.0}, |
| 557 | + ... |
| 558 | + ] |
| 559 | + } |
| 560 | + Values are in Wh (no unit_of_measurement on the sensor). |
| 561 | + """ |
| 562 | + |
| 563 | + def _make_provider_wh(self, pv_installations, timezone): |
| 564 | + """Helper: create a provider configured for Wh (evcc sensor)""" |
| 565 | + return ForecastSolarHomeAssistantML( |
| 566 | + pvinstallations=pv_installations, |
| 567 | + timezone=timezone, |
| 568 | + base_url="http://homeassistant.local:8123", |
| 569 | + api_token="test_token", |
| 570 | + entity_id="sensor.solar_forecast_ml_evcc_solar_prognose", |
| 571 | + sensor_unit="Wh" |
| 572 | + ) |
| 573 | + |
| 574 | + def _hour_str(self, tz, offset_hours: int) -> str: |
| 575 | + """Return an ISO timestamp string for current-hour + offset_hours in local tz.""" |
| 576 | + now = datetime.datetime.now(tz) |
| 577 | + hour_start = now.replace(minute=0, second=0, microsecond=0) |
| 578 | + target = hour_start + datetime.timedelta(hours=offset_hours) |
| 579 | + # Return naive local time (as the sensor provides) |
| 580 | + return target.strftime("%Y-%m-%dT%H:%M:%S") |
| 581 | + |
| 582 | + def test_parse_forecast_list_basic(self, pv_installations, timezone): |
| 583 | + """Test parsing evcc-style forecast list - current and future entries mapped correctly""" |
| 584 | + provider = self._make_provider_wh(pv_installations, timezone) |
| 585 | + |
| 586 | + attributes = { |
| 587 | + "forecast": [ |
| 588 | + {"start": self._hour_str(timezone, 0), "end": self._hour_str(timezone, 1), |
| 589 | + "value": 3613.0}, |
| 590 | + {"start": self._hour_str(timezone, 1), "end": self._hour_str(timezone, 2), |
| 591 | + "value": 1540.0}, |
| 592 | + {"start": self._hour_str(timezone, 2), "end": self._hour_str(timezone, 3), |
| 593 | + "value": 800.0}, |
| 594 | + ] |
| 595 | + } |
| 596 | + |
| 597 | + forecast = provider._parse_forecast_from_attributes(attributes) |
| 598 | + |
| 599 | + assert forecast[0] == 3613.0 |
| 600 | + assert forecast[1] == 1540.0 |
| 601 | + assert forecast[2] == 800.0 |
| 602 | + |
| 603 | + def test_parse_forecast_list_skips_past_entries(self, pv_installations, timezone): |
| 604 | + """Test that past entries (before current hour) are skipped""" |
| 605 | + provider = self._make_provider_wh(pv_installations, timezone) |
| 606 | + |
| 607 | + attributes = { |
| 608 | + "forecast": [ |
| 609 | + # Past entries |
| 610 | + {"start": self._hour_str(timezone, -3), "end": self._hour_str(timezone, -2), |
| 611 | + "value": 9999.0}, |
| 612 | + {"start": self._hour_str(timezone, -1), "end": self._hour_str(timezone, 0), |
| 613 | + "value": 8888.0}, |
| 614 | + # Current and future |
| 615 | + {"start": self._hour_str(timezone, 0), "end": self._hour_str(timezone, 1), |
| 616 | + "value": 800.0}, |
| 617 | + {"start": self._hour_str(timezone, 1), "end": self._hour_str(timezone, 2), |
| 618 | + "value": 200.0}, |
| 619 | + ] |
| 620 | + } |
| 621 | + |
| 622 | + forecast = provider._parse_forecast_from_attributes(attributes) |
| 623 | + |
| 624 | + assert forecast[0] == 800.0 |
| 625 | + assert forecast[1] == 200.0 |
| 626 | + # Past hours must not appear |
| 627 | + assert -3 not in forecast |
| 628 | + assert -1 not in forecast |
| 629 | + assert len(forecast) == 2 |
| 630 | + |
| 631 | + def test_parse_forecast_list_multi_day(self, pv_installations, timezone): |
| 632 | + """Test that forecast entries 12 and 24 hours ahead map to correct offsets. |
| 633 | +
|
| 634 | + We deliberately avoid 48h offsets here because the test date (March 27) |
| 635 | + is 2 days before the DST transition (March 29) in Europe/Berlin, |
| 636 | + which would cause wall-clock vs absolute-time discrepancy. |
| 637 | + """ |
| 638 | + provider = self._make_provider_wh(pv_installations, timezone) |
| 639 | + |
| 640 | + attributes = { |
| 641 | + "forecast": [ |
| 642 | + {"start": self._hour_str(timezone, 0), "end": self._hour_str(timezone, 1), |
| 643 | + "value": 0.0}, |
| 644 | + {"start": self._hour_str(timezone, 12), "end": self._hour_str(timezone, 13), |
| 645 | + "value": 5000.0}, |
| 646 | + {"start": self._hour_str(timezone, 24), "end": self._hour_str(timezone, 25), |
| 647 | + "value": 3000.0}, |
| 648 | + ] |
| 649 | + } |
| 650 | + |
| 651 | + forecast = provider._parse_forecast_from_attributes(attributes) |
| 652 | + |
| 653 | + assert forecast[0] == 0.0 |
| 654 | + assert forecast[12] == 5000.0 |
| 655 | + assert forecast[24] == 3000.0 |
| 656 | + |
| 657 | + def test_parse_forecast_list_wh_no_conversion(self, pv_installations, timezone): |
| 658 | + """Test that Wh values from forecast list are NOT multiplied (factor=1.0)""" |
| 659 | + provider = self._make_provider_wh(pv_installations, timezone) |
| 660 | + |
| 661 | + attributes = { |
| 662 | + "forecast": [ |
| 663 | + {"start": self._hour_str(timezone, 0), "end": self._hour_str(timezone, 1), |
| 664 | + "value": 7835.0}, |
| 665 | + ] |
| 666 | + } |
| 667 | + |
| 668 | + forecast = provider._parse_forecast_from_attributes(attributes) |
| 669 | + |
| 670 | + # sensor_unit=Wh → unit_conversion_factor=1.0 → no multiplication |
| 671 | + assert forecast[0] == 7835.0 |
| 672 | + |
| 673 | + def test_parse_forecast_list_invalid_entries_skipped(self, pv_installations, timezone): |
| 674 | + """Test that entries with invalid start timestamps or missing values are skipped""" |
| 675 | + provider = self._make_provider_wh(pv_installations, timezone) |
| 676 | + |
| 677 | + attributes = { |
| 678 | + "forecast": [ |
| 679 | + # Valid |
| 680 | + {"start": self._hour_str(timezone, 0), "end": self._hour_str(timezone, 1), |
| 681 | + "value": 1000.0}, |
| 682 | + # Invalid start timestamp |
| 683 | + {"start": "not-a-date", |
| 684 | + "end": self._hour_str(timezone, 2), "value": 500.0}, |
| 685 | + # Valid (no 'end' key is fine) |
| 686 | + {"start": self._hour_str(timezone, 2), "value": 2000.0}, |
| 687 | + # Missing start → skipped |
| 688 | + {"end": self._hour_str(timezone, 3), "value": 300.0}, |
| 689 | + # Missing value → skipped |
| 690 | + {"start": self._hour_str(timezone, 4), |
| 691 | + "end": self._hour_str(timezone, 5)}, |
| 692 | + ] |
| 693 | + } |
| 694 | + |
| 695 | + forecast = provider._parse_forecast_from_attributes(attributes) |
| 696 | + |
| 697 | + assert forecast[0] == 1000.0 # valid entry at offset 0 |
| 698 | + assert forecast[2] == 2000.0 # valid entry at offset 2 |
| 699 | + assert 4 not in forecast # no value |
| 700 | + # no start entries must not appear |
| 701 | + assert len([k for k in forecast if k >= 0]) == 2 |
| 702 | + |
| 703 | + def test_parse_forecast_list_priority_over_hours_list(self, pv_installations, timezone): |
| 704 | + """Test that 'forecast' list takes priority over 'hours_list' when both are present""" |
| 705 | + provider = self._make_provider_wh(pv_installations, timezone) |
| 706 | + |
| 707 | + attributes = { |
| 708 | + "forecast": [ |
| 709 | + {"start": self._hour_str(timezone, 0), "end": self._hour_str(timezone, 1), |
| 710 | + "value": 111.0}, |
| 711 | + ], |
| 712 | + "hours_list": [ |
| 713 | + {"time": "10:00", "kwh": 9.0}, |
| 714 | + {"time": "11:00", "kwh": 9.0}, |
| 715 | + ] |
| 716 | + } |
| 717 | + |
| 718 | + forecast = provider._parse_forecast_from_attributes(attributes) |
| 719 | + |
| 720 | + # forecast list wins |
| 721 | + assert forecast[0] == 111.0 |
| 722 | + assert len(forecast) == 1 |
| 723 | + |
| 724 | + def test_auto_detect_none_unit_defaults_to_wh(self, pv_installations, timezone): |
| 725 | + """Test that auto-detecting a sensor with unit_of_measurement=None defaults to Wh""" |
| 726 | + provider_state = { |
| 727 | + "entity_id": "sensor.solar_forecast_ml_evcc_solar_prognose", |
| 728 | + "state": "69 slots", |
| 729 | + "attributes": { |
| 730 | + "forecast": [], |
| 731 | + "friendly_name": "Solar Forecast ML evcc Solar-Prognose" |
| 732 | + # no unit_of_measurement key → evaluates to None |
| 733 | + } |
| 734 | + } |
| 735 | + |
| 736 | + # Build a provider with explicit Wh unit, then directly test the async |
| 737 | + # unit-check by calling _check_sensor_unit_async with a mocked WebSocket. |
| 738 | + provider = ForecastSolarHomeAssistantML( |
| 739 | + pvinstallations=pv_installations, |
| 740 | + timezone=timezone, |
| 741 | + base_url="http://homeassistant.local:8123", |
| 742 | + api_token="test_token", |
| 743 | + entity_id="sensor.solar_forecast_ml_evcc_solar_prognose", |
| 744 | + sensor_unit="Wh" # start with an explicit unit; we'll override below |
| 745 | + ) |
| 746 | + |
| 747 | + # Patch _websocket_connect and the subsequent recv messages so that the |
| 748 | + # async unit-check path returns an entity with no unit_of_measurement. |
| 749 | + mock_ws = AsyncMock() |
| 750 | + mock_ws.recv = AsyncMock(side_effect=[ |
| 751 | + json.dumps({"type": "auth_required", "ha_version": "2026.3.0"}), |
| 752 | + json.dumps({"type": "auth_ok", "ha_version": "2026.3.0"}), |
| 753 | + json.dumps({"type": "result", "id": 1, "success": True, |
| 754 | + "result": [provider_state]}), |
| 755 | + ]) |
| 756 | + mock_ws.close = AsyncMock() |
| 757 | + |
| 758 | + with patch( |
| 759 | + 'src.batcontrol.forecastsolar.forecast_homeassistant_ml.connect', |
| 760 | + new_callable=AsyncMock, |
| 761 | + return_value=mock_ws |
| 762 | + ): |
| 763 | + import asyncio |
| 764 | + factor = asyncio.run(provider._check_sensor_unit_async()) |
| 765 | + |
| 766 | + # unit_of_measurement is None → should default to 1.0 (Wh) with a warning |
| 767 | + assert factor == 1.0 |
| 768 | + |
| 769 | + |
547 | 770 | if __name__ == "__main__": |
548 | 771 | pytest.main([__file__, "-v"]) |
0 commit comments