diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e2b656a..ddc56ae 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -109,7 +109,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.11" + python-version: "3.14" cache: "pip" - name: Install dependencies diff --git a/CHANGELOG.md b/CHANGELOG.md index 65a37e5..942ae99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.6.0] - 2026-07-30 ### Added - **Battery pack sensors**: Exposes battery voltage in volts, signed battery current in amperes, calculated battery power in watts, and battery temperature in degrees Celsius when supported by the device TSL. Battery power is positive while charging and negative while discharging. @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Battery voltage defaults to two decimal places and battery power defaults to one decimal place in Home Assistant displays. - Upgraded to unofficial-pecron-api v0.4.1 (adds `eco_onoff_us` as an alternate property code for Eco Silent Mode on some device models) +- Tested and developed against Home Assistant 2026.7.4 (up from 2024.1.0); raised the declared minimum supported version to 2024.11.0 +- `DataUpdateCoordinator` now passes `config_entry` explicitly instead of relying on the deprecated ContextVar lookup, which Home Assistant 2026.8 turns into a hard failure ### Fixed - Read battery voltage, current, and temperature from the nested battery packet exposed by the Pecron API and discover all three sensors through its `host_packet_data_jdb` TSL property. diff --git a/custom_components/pecron/__init__.py b/custom_components/pecron/__init__.py index 5ae1b2c..421e840 100644 --- a/custom_components/pecron/__init__.py +++ b/custom_components/pecron/__init__.py @@ -43,7 +43,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data.get(CONF_REFRESH_INTERVAL, DEFAULT_REFRESH_INTERVAL), ) - coordinator = PecronDataUpdateCoordinator(hass, email, password, region, refresh_interval) + coordinator = PecronDataUpdateCoordinator( + hass, entry, email, password, region, refresh_interval + ) # Attempt initial refresh with retry logic max_retries = 3 @@ -273,6 +275,7 @@ class PecronDataUpdateCoordinator(DataUpdateCoordinator): def __init__( self, hass: HomeAssistant, + config_entry: ConfigEntry, email: str, password: str, region: str, @@ -289,6 +292,7 @@ def __init__( super().__init__( hass, _LOGGER, + config_entry=config_entry, name=DOMAIN, update_interval=timedelta(seconds=refresh_interval), ) diff --git a/custom_components/pecron/manifest.json b/custom_components/pecron/manifest.json index 896fc30..9b992f1 100644 --- a/custom_components/pecron/manifest.json +++ b/custom_components/pecron/manifest.json @@ -11,9 +11,9 @@ "requirements": [ "unofficial-pecron-api>=0.4.1" ], - "version": "0.5.0", + "version": "0.6.0", "iot_class": "cloud_polling", "homeassistant": { - "minimum_version": "2024.1.0" + "minimum_version": "2024.11.0" } } diff --git a/pyproject.toml b/pyproject.toml index 95a52e5..c5c6ef0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ ignore_missing_imports = true [[tool.mypy.overrides]] module = "homeassistant.*" ignore_missing_imports = true +follow_imports = "skip" [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/requirements-dev.txt b/requirements-dev.txt index ffadc2b..eae0d64 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,9 +1,9 @@ ruff==0.1.14 -mypy==1.8.0 +mypy==1.15.0 pytest==7.4.4 pytest-cov==4.1.0 pytest-asyncio==0.23.2 pre-commit==3.6.0 -homeassistant==2024.1.0 +homeassistant==2026.7.4 types-aiofiles==23.2.0.20240106 unofficial-pecron-api==0.4.1 diff --git a/tests/test_token_refresh.py b/tests/test_token_refresh.py index 1b8530b..01b76bd 100644 --- a/tests/test_token_refresh.py +++ b/tests/test_token_refresh.py @@ -17,6 +17,12 @@ def mock_hass(): return hass +@pytest.fixture +def mock_config_entry(): + """Create a mock config entry.""" + return MagicMock() + + @pytest.fixture def mock_pecron_api(): """Create a mock PecronAPI.""" @@ -56,12 +62,12 @@ class TestTokenRefreshDuringDeviceListFetch: @pytest.mark.asyncio async def test_auth_failure_on_initial_fetch_triggers_retry( - self, mock_hass, mock_pecron_api, mock_device, mock_properties + self, mock_hass, mock_config_entry, mock_pecron_api, mock_device, mock_properties ): """Test that auth failure during initial device fetch triggers retry.""" with patch("custom_components.pecron.PecronAPI", return_value=mock_pecron_api): coordinator = PecronDataUpdateCoordinator( - mock_hass, "test@example.com", "password", "US", 600 + mock_hass, mock_config_entry, "test@example.com", "password", "US", 600 ) # First call fails with auth error, second succeeds @@ -83,7 +89,7 @@ class TestTokenRefreshDuringPropertyFetch: @pytest.mark.asyncio async def test_auth_failure_during_property_fetch_triggers_retry( - self, mock_hass, mock_pecron_api, mock_device, mock_properties + self, mock_hass, mock_config_entry, mock_pecron_api, mock_device, mock_properties ): """Test that auth failure during property fetch triggers retry and API reset.""" call_count = 0 @@ -97,7 +103,7 @@ def property_side_effect(device): with patch("custom_components.pecron.PecronAPI", return_value=mock_pecron_api): coordinator = PecronDataUpdateCoordinator( - mock_hass, "test@example.com", "password", "US", 600 + mock_hass, mock_config_entry, "test@example.com", "password", "US", 600 ) # Setup: initial fetch succeeds @@ -119,7 +125,7 @@ def property_side_effect(device): @pytest.mark.asyncio async def test_error_5032_detected_and_api_reset( - self, mock_hass, mock_device, mock_properties + self, mock_hass, mock_config_entry, mock_device, mock_properties ): """Test that error code 5032 is detected, API is reset, and retries work.""" call_count = 0 @@ -147,7 +153,7 @@ def create_api(*args, **kwargs): mock_api_class.side_effect = create_api coordinator = PecronDataUpdateCoordinator( - mock_hass, "test@example.com", "password", "US", 600 + mock_hass, mock_config_entry, "test@example.com", "password", "US", 600 ) # Initial refresh: should fail once with 5032, then retry and succeed @@ -160,7 +166,7 @@ def create_api(*args, **kwargs): @pytest.mark.asyncio async def test_token_string_in_error_triggers_reset( - self, mock_hass, mock_device, mock_properties + self, mock_hass, mock_config_entry, mock_device, mock_properties ): """Test that 'token' in error message triggers API reset and retry.""" call_count = 0 @@ -181,7 +187,7 @@ def property_side_effect(device): mock_api_class.return_value = api coordinator = PecronDataUpdateCoordinator( - mock_hass, "test@example.com", "password", "US", 600 + mock_hass, mock_config_entry, "test@example.com", "password", "US", 600 ) # Should detect 'token' and retry successfully @@ -190,7 +196,7 @@ def property_side_effect(device): @pytest.mark.asyncio async def test_authentication_string_triggers_reset( - self, mock_hass, mock_device, mock_properties + self, mock_hass, mock_config_entry, mock_device, mock_properties ): """Test that 'authentication' in error message triggers API reset and retry.""" call_count = 0 @@ -211,7 +217,7 @@ def property_side_effect(device): mock_api_class.return_value = api coordinator = PecronDataUpdateCoordinator( - mock_hass, "test@example.com", "password", "US", 600 + mock_hass, mock_config_entry, "test@example.com", "password", "US", 600 ) await coordinator.async_refresh() @@ -219,7 +225,7 @@ def property_side_effect(device): @pytest.mark.asyncio async def test_401_error_triggers_reset( - self, mock_hass, mock_device, mock_properties + self, mock_hass, mock_config_entry, mock_device, mock_properties ): """Test that 401 error code triggers API reset and retry.""" call_count = 0 @@ -240,7 +246,7 @@ def property_side_effect(device): mock_api_class.return_value = api coordinator = PecronDataUpdateCoordinator( - mock_hass, "test@example.com", "password", "US", 600 + mock_hass, mock_config_entry, "test@example.com", "password", "US", 600 ) await coordinator.async_refresh() @@ -248,7 +254,7 @@ def property_side_effect(device): @pytest.mark.asyncio async def test_unauthorized_string_triggers_reset( - self, mock_hass, mock_device, mock_properties + self, mock_hass, mock_config_entry, mock_device, mock_properties ): """Test that 'unauthorized' in error message triggers API reset and retry.""" call_count = 0 @@ -269,7 +275,7 @@ def property_side_effect(device): mock_api_class.return_value = api coordinator = PecronDataUpdateCoordinator( - mock_hass, "test@example.com", "password", "US", 600 + mock_hass, mock_config_entry, "test@example.com", "password", "US", 600 ) await coordinator.async_refresh() @@ -281,12 +287,12 @@ class TestNonAuthErrorsDontTriggerReset: @pytest.mark.asyncio async def test_connection_error_does_not_reset_api( - self, mock_hass, mock_pecron_api, mock_device, mock_properties + self, mock_hass, mock_config_entry, mock_pecron_api, mock_device, mock_properties ): """Test that connection errors don't reset API.""" with patch("custom_components.pecron.PecronAPI", return_value=mock_pecron_api): coordinator = PecronDataUpdateCoordinator( - mock_hass, "test@example.com", "password", "US", 600 + mock_hass, mock_config_entry, "test@example.com", "password", "US", 600 ) mock_pecron_api.get_devices.return_value = [mock_device] @@ -295,9 +301,7 @@ async def test_connection_error_does_not_reset_api( original_api = coordinator.api # Property fetch fails with connection error (non-auth) - mock_pecron_api.get_device_properties.side_effect = Exception( - "Connection timeout" - ) + mock_pecron_api.get_device_properties.side_effect = Exception("Connection timeout") # Should not raise (error is caught and logged for individual device) await coordinator.async_refresh() @@ -307,12 +311,12 @@ async def test_connection_error_does_not_reset_api( @pytest.mark.asyncio async def test_generic_error_does_not_reset_api( - self, mock_hass, mock_pecron_api, mock_device, mock_properties + self, mock_hass, mock_config_entry, mock_pecron_api, mock_device, mock_properties ): """Test that generic errors don't reset API.""" with patch("custom_components.pecron.PecronAPI", return_value=mock_pecron_api): coordinator = PecronDataUpdateCoordinator( - mock_hass, "test@example.com", "password", "US", 600 + mock_hass, mock_config_entry, "test@example.com", "password", "US", 600 ) mock_pecron_api.get_devices.return_value = [mock_device] @@ -320,9 +324,7 @@ async def test_generic_error_does_not_reset_api( original_api = coordinator.api - mock_pecron_api.get_device_properties.side_effect = Exception( - "Something went wrong" - ) + mock_pecron_api.get_device_properties.side_effect = Exception("Something went wrong") await coordinator.async_refresh() @@ -335,7 +337,7 @@ class TestPartialSuccessScenarios: @pytest.mark.asyncio async def test_auth_error_on_first_device_stops_processing( - self, mock_hass, mock_properties + self, mock_hass, mock_config_entry, mock_properties ): """Test that auth error on first device triggers retry and processes both devices.""" device1 = MagicMock() @@ -367,7 +369,7 @@ def property_side_effect(device): mock_api_class.return_value = api coordinator = PecronDataUpdateCoordinator( - mock_hass, "test@example.com", "password", "US", 600 + mock_hass, mock_config_entry, "test@example.com", "password", "US", 600 ) # Auth error on first device should trigger retry, then process both devices @@ -381,20 +383,17 @@ class TestRetryLogic: """Test the retry logic in _async_update_data.""" @pytest.mark.asyncio - async def test_max_retries_respected( - self, mock_hass, mock_device - ): + async def test_max_retries_respected(self, mock_hass, mock_config_entry, mock_device): """Test that max retries (2) is respected when auth keeps failing.""" api_instances = [] with patch("custom_components.pecron.PecronAPI") as mock_api_class: + def create_api(*args, **kwargs): api = MagicMock() api.login = MagicMock() # Always fail with auth error - api.get_devices.side_effect = PecronAPIError( - "Token validation failed", code=5032 - ) + api.get_devices.side_effect = PecronAPIError("Token validation failed", code=5032) api.get_product_tsl.return_value = [] api_instances.append(api) return api @@ -402,7 +401,7 @@ def create_api(*args, **kwargs): mock_api_class.side_effect = create_api coordinator = PecronDataUpdateCoordinator( - mock_hass, "test@example.com", "password", "US", 600 + mock_hass, mock_config_entry, "test@example.com", "password", "US", 600 ) # All attempts fail with auth error, should exhaust retries @@ -417,7 +416,7 @@ def create_api(*args, **kwargs): @pytest.mark.asyncio async def test_successful_retry_after_auth_failure( - self, mock_hass, mock_device, mock_properties + self, mock_hass, mock_config_entry, mock_device, mock_properties ): """Test successful retry after initial auth failure.""" call_count = 0 @@ -438,7 +437,7 @@ def side_effect_get_properties(device): mock_api_class.return_value = mock_api coordinator = PecronDataUpdateCoordinator( - mock_hass, "test@example.com", "password", "US", 600 + mock_hass, mock_config_entry, "test@example.com", "password", "US", 600 ) # First attempt fails, second succeeds @@ -454,7 +453,7 @@ class TestCaseInsensitiveErrorDetection: @pytest.mark.asyncio async def test_uppercase_token_detected( - self, mock_hass, mock_device, mock_properties + self, mock_hass, mock_config_entry, mock_device, mock_properties ): """Test that 'TOKEN' (uppercase) is detected.""" call_count = 0 @@ -475,7 +474,7 @@ def property_side_effect(device): mock_api_class.return_value = api coordinator = PecronDataUpdateCoordinator( - mock_hass, "test@example.com", "password", "US", 600 + mock_hass, mock_config_entry, "test@example.com", "password", "US", 600 ) # Should detect 'TOKEN' (case-insensitive) and retry @@ -484,7 +483,7 @@ def property_side_effect(device): @pytest.mark.asyncio async def test_mixed_case_authentication_detected( - self, mock_hass, mock_device, mock_properties + self, mock_hass, mock_config_entry, mock_device, mock_properties ): """Test that 'Authentication' (mixed case) is detected.""" call_count = 0 @@ -505,7 +504,7 @@ def property_side_effect(device): mock_api_class.return_value = api coordinator = PecronDataUpdateCoordinator( - mock_hass, "test@example.com", "password", "US", 600 + mock_hass, mock_config_entry, "test@example.com", "password", "US", 600 ) # Should detect 'Authentication' (case-insensitive) and retry @@ -518,12 +517,13 @@ class TestAPIReinitialization: @pytest.mark.asyncio async def test_api_reinitialized_after_reset( - self, mock_hass, mock_device, mock_properties + self, mock_hass, mock_config_entry, mock_device, mock_properties ): """Test that setting api=None triggers fresh login on next fetch.""" login_calls = [] with patch("custom_components.pecron.PecronAPI") as mock_api_class: + def create_api(*args, **kwargs): api = MagicMock() api.login = MagicMock(side_effect=lambda *a: login_calls.append(a)) @@ -535,7 +535,7 @@ def create_api(*args, **kwargs): mock_api_class.side_effect = create_api coordinator = PecronDataUpdateCoordinator( - mock_hass, "test@example.com", "password", "US", 600 + mock_hass, mock_config_entry, "test@example.com", "password", "US", 600 ) # First refresh @@ -551,12 +551,12 @@ def create_api(*args, **kwargs): @pytest.mark.asyncio async def test_token_refresh_logged( - self, mock_hass, mock_pecron_api, mock_device, mock_properties + self, mock_hass, mock_config_entry, mock_pecron_api, mock_device, mock_properties ): """Test that token refresh is logged appropriately.""" with patch("custom_components.pecron.PecronAPI", return_value=mock_pecron_api): coordinator = PecronDataUpdateCoordinator( - mock_hass, "test@example.com", "password", "US", 600 + mock_hass, mock_config_entry, "test@example.com", "password", "US", 600 ) mock_pecron_api.get_devices.return_value = [mock_device]