Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
16 changes: 11 additions & 5 deletions src/devices/color.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import colorsys
import logging
from typing import Any

Expand Down Expand Up @@ -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
Expand Down
43 changes: 39 additions & 4 deletions src/helper/compat_aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
24 changes: 20 additions & 4 deletions src/session/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
HiveApiError,
HiveFailedToRefreshTokens,
HiveInvalid2FACode,
HiveInvalidDeviceAuthentication,
HiveInvalidPassword,
HiveInvalidUsername,
HiveReauthRequired,
Expand Down Expand Up @@ -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."
Expand All @@ -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."
Expand Down
64 changes: 51 additions & 13 deletions src/session/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -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.

Expand All @@ -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")
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/session/polling.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions tests/module/test_light.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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": {
Expand Down
28 changes: 19 additions & 9 deletions tests/unit/test_color.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,30 +148,40 @@ 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}}}
)
h = _make_handler(session)
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)
Loading
Loading