diff --git a/pyproject.toml b/pyproject.toml index f0a89dd9..bbdcc823 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pyhive-integration" -version = "2.0.0.B3" +version = "2.0.0.B4" description = "A Python library to interface with the Hive API" readme = "README.md" license = { text = "MIT" } diff --git a/src/devices/color.py b/src/devices/color.py index 8a63a9db..11b1f536 100644 --- a/src/devices/color.py +++ b/src/devices/color.py @@ -2,6 +2,7 @@ from __future__ import annotations +import colorsys import logging from typing import Any @@ -71,18 +72,23 @@ async def get_color_temp(self, device: Device): return None async def get_color(self, device: Device): - """Get light current colour as an HS tuple for HA hs_color. + """Get light current colour as an RGB tuple. + + Home Assistant reads ``status["hs_color"]`` and converts it with + ``color_RGB_to_hs(*rgb)``, so this must stay an RGB 3-tuple. Args: device (Device): Device to query. Returns: - tuple | None: ``(hue_degrees, saturation_percent)`` where hue is - 0–360 and saturation is 0–100, or None on error. + tuple | None: ``(red, green, blue)``, each 0–255, or None on error. """ try: - data = self.session.data.products[device.hive_id] - return (data["state"]["hue"], data["state"]["saturation"]) + state = self.session.data.products[device.hive_id]["state"] + rgb = colorsys.hsv_to_rgb( + state["hue"] / 360, state["saturation"] / 100, state["value"] / 100 + ) + return tuple(round(channel * 255) for channel in rgb) except KeyError as e: _LOGGER.error(e) return None diff --git a/src/helper/compat_aliases.py b/src/helper/compat_aliases.py index 8cdfc0e8..4c8ba48c 100644 --- a/src/helper/compat_aliases.py +++ b/src/helper/compat_aliases.py @@ -36,6 +36,30 @@ async def getClimate(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for get_climate.""" return await self.get_climate(device) # type: ignore[attr-defined] + async def getBoostStatus(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_boost_status.""" + return await self.get_boost_status(device) # type: ignore[attr-defined] + + async def getBoostTime(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_boost_time.""" + return await self.get_boost_time(device) # type: ignore[attr-defined] + + async def currentTemperature(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_current_temperature.""" + return await self.get_current_temperature(device) # type: ignore[attr-defined] + + async def targetTemperature(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_target_temperature.""" + return await self.get_target_temperature(device) # type: ignore[attr-defined] + + async def minmaxTemperature(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for minmax_temperature.""" + return await self.minmax_temperature(device) # type: ignore[attr-defined] + + async def getScheduleNowNextLater(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_schedule_now_next_later.""" + return await self.get_schedule_now_next_later(device) # type: ignore[attr-defined] + class LightCompatMixin: """CamelCase aliases for Light public methods.""" @@ -80,6 +104,18 @@ async def get_boost(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for get_boost_status.""" return await self.get_boost_status(device) # type: ignore[attr-defined] + async def getBoost(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_boost_status.""" + return await self.get_boost_status(device) # type: ignore[attr-defined] + + async def getBoostTime(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_boost_time.""" + return await self.get_boost_time(device) # type: ignore[attr-defined] + + async def getScheduleNowNextLater(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_schedule_now_next_later.""" + return await self.get_schedule_now_next_later(device) # type: ignore[attr-defined] + async def setMode(self, device: Device, new_mode: str): # pylint: disable=invalid-name """Backwards-compatible alias for set_mode.""" return await self.set_mode(device, new_mode) # type: ignore[attr-defined] @@ -139,7 +175,6 @@ async def updateData(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for update_data.""" return await self.update_data(device) # type: ignore[attr-defined] - async def updateInterval(self, new_interval: int): # pylint: disable=invalid-name - """Backwards-compatible alias for Home Assistant Scan Interval.""" - self.config.scan_interval = timedelta(seconds=new_interval) # type: ignore[attr-defined] - return True + async def updateInterval(self, new_interval: int | float | timedelta): # pylint: disable=invalid-name + """Backwards-compatible alias for update_interval.""" + return await self.update_interval(new_interval) # type: ignore[attr-defined] diff --git a/src/session/auth.py b/src/session/auth.py index 4f9d67d9..0cc661b0 100644 --- a/src/session/auth.py +++ b/src/session/auth.py @@ -11,6 +11,7 @@ HiveApiError, HiveFailedToRefreshTokens, HiveInvalid2FACode, + HiveInvalidDeviceAuthentication, HiveInvalidPassword, HiveInvalidUsername, HiveReauthRequired, @@ -271,12 +272,22 @@ async def _retry_login(self): Raises: HiveReauthRequired: User interaction required (SMS 2FA challenge), - credentials invalid, or all retries exhausted. - HiveApiError: API error or no internet connection. + credentials or saved device credentials + rejected, or all retries exhausted. + + Any other failure (API, network or unexpected response) is retried, and + if every attempt fails ``HiveReauthRequired`` is raised so Home Assistant + asks the user to log in again. """ async def _attempt(): - result = await self.login() + try: + result = await self.login() + except Exception as err: + _LOGGER.warning( + "_retry_login - Login attempt failed: %s", type(err).__name__ + ) + raise if result and result.get("ChallengeName") == self.auth.SMS_MFA_CHALLENGE: _LOGGER.error( "_retry_login - Login requires SMS 2FA. User must reauthenticate." @@ -292,9 +303,14 @@ async def _attempt(): HiveReauthRequired, HiveInvalidUsername, HiveInvalidPassword, + HiveInvalidDeviceAuthentication, ), ) - except (HiveInvalidUsername, HiveInvalidPassword) as exc: + except ( + HiveInvalidUsername, + HiveInvalidPassword, + HiveInvalidDeviceAuthentication, + ) as exc: _LOGGER.error( "_retry_login - Login failed with invalid credentials," " reauthentication required." diff --git a/src/session/discovery.py b/src/session/discovery.py index 68289bb1..ec835c90 100644 --- a/src/session/discovery.py +++ b/src/session/discovery.py @@ -4,6 +4,7 @@ import json import logging +from datetime import datetime from pathlib import Path from typing import Any @@ -115,6 +116,51 @@ def add_list(self, entity_type: str, data: dict, **kwargs) -> Device | None: _LOGGER.error(error) return None + @staticmethod + def _parse_token_created(value: Any) -> datetime | None: + """Return the saved token-issue time as a datetime. + + Home Assistant stores ``device_data`` as JSON, so after a restart the + datetime returned by ``get_device_data()`` comes back as an ISO string. + + Args: + value: A datetime, an ISO 8601 string, or a falsy value. + + Returns: + datetime | None: The parsed time, or None if it is missing or invalid. + """ + if isinstance(value, datetime): + return value + if isinstance(value, str) and value: + try: + return datetime.fromisoformat(value) + except ValueError: + _LOGGER.warning("start_session - Ignoring invalid token_created value") + return None + + def _apply_device_data(self, device_data: list | tuple) -> None: + """Load saved device credentials (and token issue time) into the session. + + Args: + device_data: ``(device_group_key, device_key, device_password)``, + optionally followed by ``token_created``. + + Raises: + HiveUnknownConfiguration: Fewer than three values were given. + """ + if len(device_data) < EXPECTED_DEVICE_DATA_LENGTH: + raise HiveUnknownConfiguration( + "device_data must contain device_group_key, " + "device_key and device_password" + ) + self.auth.device_group_key = device_data[0] + self.auth.device_key = device_data[1] + self.auth.device_password = device_data[2] + if len(device_data) > EXPECTED_DEVICE_DATA_LENGTH: + token_created = self._parse_token_created(device_data[3]) + if token_created is not None: + self.tokens.token_created = token_created # type: ignore[attr-defined] + async def start_session(self, config: dict | None = None): """Setup the Hive platform. @@ -136,6 +182,10 @@ async def start_session(self, config: dict | None = None): ) self._configure_file_mode(config.get("username", self.config.username)) + scan_interval = (config.get("options") or {}).get("scan_interval") + if scan_interval is not None: + await self.update_interval(scan_interval) # type: ignore[attr-defined] + if config != {}: if "tokens" in config and not self.config.file: _LOGGER.debug("start_session - Updating tokens from config") @@ -148,19 +198,7 @@ async def start_session(self, config: dict | None = None): self.auth.password = config["password"] if "device_data" in config and not self.config.file: - device_data = config["device_data"] - if len(device_data) < EXPECTED_DEVICE_DATA_LENGTH: - raise HiveUnknownConfiguration( - "device_data must contain device_group_key, " - "device_key and device_password" - ) - self.auth.device_group_key = device_data[0] - self.auth.device_key = device_data[1] - self.auth.device_password = device_data[2] - if len(device_data) > EXPECTED_DEVICE_DATA_LENGTH: - token_created = device_data[3] - if token_created: - self.tokens.token_created = token_created # type: ignore[attr-defined] + self._apply_device_data(config["device_data"]) if not self.config.file and "tokens" not in config: raise HiveUnknownConfiguration diff --git a/src/session/polling.py b/src/session/polling.py index 2cfc254d..e9ed1212 100644 --- a/src/session/polling.py +++ b/src/session/polling.py @@ -24,6 +24,9 @@ # omitted at times, so keeping their old value would report stale state. REQUIRED_STATE_KEYS = frozenset({"mode", "name", "status", "target"}) +# Shortest polling interval allowed, to stay within Hive's API rate limits. +MIN_SCAN_INTERVAL = timedelta(seconds=15) + class PollingMixin: """Device polling, rate-limiting, and entity-cache methods. @@ -144,6 +147,21 @@ def _keep_missing_state(self, new_products: dict) -> None: "; ".join(sorted(incomplete)), ) + async def update_interval(self, new_interval: int | float | timedelta) -> bool: + """Set the polling interval. + + Args: + new_interval: Seconds, or a timedelta. Values below 15 seconds are + raised to 15 seconds. + + Returns: + bool: True once the interval is set. + """ + if not isinstance(new_interval, timedelta): + new_interval = timedelta(seconds=new_interval) + self.config.scan_interval = max(new_interval, MIN_SCAN_INTERVAL) + return True + async def _poll_devices(self) -> bool: """Fetch latest device state from the Hive API.""" return await self.get_devices("No_ID") diff --git a/tests/module/test_light.py b/tests/module/test_light.py index 8910759a..82f473e4 100644 --- a/tests/module/test_light.py +++ b/tests/module/test_light.py @@ -22,7 +22,7 @@ _HSV_HUE = 120 _HSV_SAT = 100 _HSV_VAL = 100 -_COLOR_TUPLE = (_HSV_HUE, _HSV_SAT) +_COLOR_TUPLE = (0, 255, 0) def _make_light(products=None, devices=None): @@ -276,7 +276,7 @@ async def test_get_color_temp_missing_returns_none(self): assert await light.get_color_temp(_make_device()) is None async def test_get_color_returns_rgb_tuple(self): - """get_color returns an (hue, saturation) 2-tuple for HA hs_color.""" + """get_color returns an (r, g, b) 3-tuple for HA hs_color.""" light = _make_light( { "light-1": { diff --git a/tests/unit/test_color.py b/tests/unit/test_color.py index dbe830bb..404f6262 100644 --- a/tests/unit/test_color.py +++ b/tests/unit/test_color.py @@ -148,15 +148,16 @@ async def test_get_color_temp_zero_returns_none(self): # --------------------------------------------------------------------------- -# get_color — must return HS 2-tuple for HA hs_color, not RGB 3-tuple +# get_color — must return an RGB 3-tuple: Home Assistant's light.py calls +# color_RGB_to_hs(*status["hs_color"]) # --------------------------------------------------------------------------- -class TestGetColorReturnsHSTuple: - """get_color must return (hue_degrees, saturation_percent) 2-tuple for HA hs_color.""" +class TestGetColorReturnsRGBTuple: + """get_color must return an (r, g, b) 3-tuple for Home Assistant.""" - async def test_get_color_returns_two_tuple(self): - """get_color returns a 2-tuple (not 3-tuple).""" + async def test_get_color_returns_three_tuple(self): + """get_color returns a 3-tuple that Home Assistant can unpack as RGB.""" session = _make_session( {"light-1": {"state": {"hue": 120, "saturation": 75, "value": 100}}} ) @@ -164,14 +165,23 @@ async def test_get_color_returns_two_tuple(self): device = _make_device() result = await h.get_color(device) assert result is not None - assert len(result) == 2, f"Expected 2-tuple (hue, sat), got {result!r}" + assert len(result) == 3, f"Expected 3-tuple (r, g, b), got {result!r}" - async def test_get_color_returns_correct_hue_and_saturation(self): - """get_color returns (hue, saturation) values matching API data.""" + async def test_get_color_converts_hsv_to_rgb(self): + """get_color converts the API's hue/saturation/value to RGB.""" session = _make_session( {"light-1": {"state": {"hue": 180, "saturation": 50, "value": 80}}} ) h = _make_handler(session) device = _make_device() result = await h.get_color(device) - assert result == (180, 50) + assert result == (102, 204, 204) + + async def test_get_color_rounds_channels(self): + """Channels are rounded, not truncated (2.55 -> 3, not 2).""" + session = _make_session( + {"light-1": {"state": {"hue": 0, "saturation": 99, "value": 100}}} + ) + h = _make_handler(session) + result = await h.get_color(_make_device()) + assert result == (255, 3, 3) diff --git a/tests/unit/test_compat_aliases.py b/tests/unit/test_compat_aliases.py index 8cc255fe..9b7d57ee 100644 --- a/tests/unit/test_compat_aliases.py +++ b/tests/unit/test_compat_aliases.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock +import pytest from apyhiveapi.helper.compat_aliases import ( ActionCompatMixin, HeatingCompatMixin, @@ -14,6 +15,7 @@ WaterHeaterCompatMixin, ) from apyhiveapi.helper.hivedataclasses import Device, SessionConfig +from apyhiveapi.session.polling import PollingMixin def _make_device(): @@ -323,7 +325,7 @@ class Stub(SessionCompatMixin): async def test_update_interval_returns_true(self): """updateInterval returns True and updates config.scan_interval.""" - class Stub(SessionCompatMixin): + class Stub(SessionCompatMixin, PollingMixin): """Stub for updateInterval test.""" device_list = {} @@ -344,7 +346,7 @@ def __init__(self): def _make_concrete_session(): """Return a minimal SessionCompatMixin subclass with a real SessionConfig.""" - class ConcreteSession(SessionCompatMixin): + class ConcreteSession(SessionCompatMixin, PollingMixin): """Minimal concrete SessionCompatMixin for updateInterval tests.""" def __init__(self): @@ -377,6 +379,60 @@ async def test_update_interval_returns_true(self): result = await session.updateInterval(60) assert result is True + async def test_update_interval_accepts_timedelta(self): + """updateInterval accepts a timedelta, as Hive 1.x did.""" + from datetime import timedelta + + session = _make_concrete_session() + await session.updateInterval(timedelta(minutes=5)) + assert session.config.scan_interval == timedelta(minutes=5) + + async def test_update_interval_enforces_minimum(self): + """updateInterval raises intervals below 15 seconds to 15 seconds.""" + from datetime import timedelta + + session = _make_concrete_session() + await session.updateInterval(5) + assert session.config.scan_interval == timedelta(seconds=15) + + +# --------------------------------------------------------------------------- +# camelCase aliases the Hive custom component calls (custom sensor.py) +# --------------------------------------------------------------------------- + + +class TestCustomComponentAliases: + """Aliases used by Pyhass/Hive-Custom-Component must delegate correctly.""" + + @pytest.mark.parametrize( + ("mixin", "alias", "target"), + [ + (HeatingCompatMixin, "getBoostStatus", "get_boost_status"), + (HeatingCompatMixin, "getBoostTime", "get_boost_time"), + (HeatingCompatMixin, "currentTemperature", "get_current_temperature"), + (HeatingCompatMixin, "targetTemperature", "get_target_temperature"), + (HeatingCompatMixin, "minmaxTemperature", "minmax_temperature"), + ( + HeatingCompatMixin, + "getScheduleNowNextLater", + "get_schedule_now_next_later", + ), + (WaterHeaterCompatMixin, "getBoost", "get_boost_status"), + (WaterHeaterCompatMixin, "getBoostTime", "get_boost_time"), + ( + WaterHeaterCompatMixin, + "getScheduleNowNextLater", + "get_schedule_now_next_later", + ), + ], + ) + async def test_alias_delegates(self, mixin, alias, target): + """Each alias awaits its snake_case method and returns its result.""" + stub = type("Stub", (mixin,), {target: AsyncMock(return_value="result")})() + d = _make_device() + assert await getattr(stub, alias)(d) == "result" + getattr(stub, target).assert_called_once_with(d) + # --------------------------------------------------------------------------- # Migrated from test_compat_aliases_extended.py diff --git a/tests/unit/test_session_auth.py b/tests/unit/test_session_auth.py index 87e9967c..6c50d533 100644 --- a/tests/unit/test_session_auth.py +++ b/tests/unit/test_session_auth.py @@ -3,18 +3,21 @@ # pylint: disable=attribute-defined-outside-init,too-few-public-methods,protected-access import asyncio from datetime import datetime, timedelta -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch +import aiohttp import pytest from apyhiveapi.helper.hive_exceptions import ( HiveApiError, HiveFailedToRefreshTokens, + HiveInvalidDeviceAuthentication, HiveInvalidUsername, HiveReauthRequired, HiveUnknownConfiguration, ) from apyhiveapi.helper.hivedataclasses import SessionConfig, SessionTokens from apyhiveapi.session.auth import SessionAuthMixin +from botocore.exceptions import ReadTimeoutError AUTH_RESULT = { "AuthenticationResult": { @@ -256,6 +259,56 @@ async def test_invalid_username_converted_to_reauth(self): with pytest.raises(HiveReauthRequired): await s._retry_login() + async def test_api_error_retried_then_reauth(self): + """HiveApiError is retried on every attempt, then becomes HiveReauthRequired.""" + s = _make_stub() + s.auth.login.side_effect = HiveApiError() + with ( + patch("asyncio.sleep", AsyncMock()), + pytest.raises(HiveReauthRequired), + ): + await s._retry_login() + assert s.auth.login.call_count == 3 + + async def test_invalid_device_auth_converted_to_reauth_without_retry(self): + """Rejected device credentials need a new SMS login, so reauth at once.""" + s = _make_stub() + s.auth.login.side_effect = HiveInvalidDeviceAuthentication() + with pytest.raises(HiveReauthRequired): + await s._retry_login() + assert s.auth.login.call_count == 1 + + @pytest.mark.parametrize( + "error", + [ + ReadTimeoutError(endpoint_url="https://cognito-idp"), + aiohttp.ClientConnectionError("Cannot connect"), + KeyError("ChallengeName"), + HiveUnknownConfiguration(), + ], + ) + async def test_any_failure_retried_then_reauth(self, error): + """Network, malformed-response and library errors are retried, then reauth.""" + s = _make_stub() + s.auth.login.side_effect = error + with ( + patch("asyncio.sleep", AsyncMock()), + pytest.raises(HiveReauthRequired), + ): + await s._retry_login() + assert s.auth.login.call_count == 3 + + async def test_transient_failure_recovers_on_retry(self): + """A failure followed by a successful login completes without reauth.""" + s = _make_stub() + s.auth.login.side_effect = [ + aiohttp.ClientConnectionError("Cannot connect"), + AUTH_RESULT, + ] + with patch("asyncio.sleep", AsyncMock()): + await s._retry_login() + assert s.auth.login.call_count == 2 + async def test_invalid_password_converted_to_reauth(self): """HiveInvalidPassword from login() is converted to HiveReauthRequired.""" from apyhiveapi.helper.hive_exceptions import HiveInvalidPassword @@ -443,8 +496,6 @@ class TestRetryWithBackoffNonZeroDelay: async def test_non_zero_delay_is_awaited_but_succeeds(self): """A non-zero delay entry causes asyncio.sleep to be called; factory still runs.""" - from unittest.mock import patch - s = _make_stub() calls = [] @@ -462,8 +513,6 @@ async def factory(): async def test_zero_delay_does_not_call_sleep(self): """A zero delay skips asyncio.sleep.""" - from unittest.mock import patch - s = _make_stub() async def factory(): diff --git a/tests/unit/test_session_discovery.py b/tests/unit/test_session_discovery.py index baec701c..36c9b664 100644 --- a/tests/unit/test_session_discovery.py +++ b/tests/unit/test_session_discovery.py @@ -168,6 +168,61 @@ async def test_with_device_data_4_items_sets_token_created(self): ) assert s.tokens.token_created == created_ts + async def test_with_device_data_iso_string_token_created_is_parsed(self): + """An ISO string token_created (as stored by HA after a restart) is parsed.""" + s = _make_stub() + s.config.file = False + created_ts = datetime(2024, 1, 15, 10, 30, 0, 123456) + await s.start_session( + { + "tokens": {}, + "device_data": [ + "grp-key", + "dev-key", + "dev-pass", + created_ts.isoformat(), + ], + } + ) + assert s.tokens.token_created == created_ts + + async def test_with_device_data_invalid_token_created_is_ignored(self): + """An unparsable token_created string leaves token_created unchanged.""" + s = _make_stub() + s.config.file = False + original_created = s.tokens.token_created + await s.start_session( + { + "tokens": {}, + "device_data": ["grp-key", "dev-key", "dev-pass", "not-a-date"], + } + ) + assert s.tokens.token_created == original_created + + async def test_scan_interval_option_is_applied(self): + """options.scan_interval from the HA config entry sets the poll interval.""" + s = _make_stub() + s.config.file = False + s.update_interval = AsyncMock() + await s.start_session({"tokens": {}, "options": {"scan_interval": 300}}) + s.update_interval.assert_called_once_with(300) + + async def test_options_none_keeps_default(self): + """An explicit options=None is treated as no options.""" + s = _make_stub() + s.config.file = False + s.update_interval = AsyncMock() + await s.start_session({"tokens": {}, "options": None}) + s.update_interval.assert_not_called() + + async def test_no_scan_interval_option_keeps_default(self): + """Without options.scan_interval the default interval is left alone.""" + s = _make_stub() + s.config.file = False + s.update_interval = AsyncMock() + await s.start_session({"tokens": {}}) + s.update_interval.assert_not_called() + async def test_with_device_data_4_items_none_token_created_not_set(self): """4-item device_data where token_created is None — does not overwrite token_created.""" s = _make_stub()