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
21 changes: 7 additions & 14 deletions src/devices/heating.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,20 +164,13 @@ async def get_mode(self, device: Device):
Returns:
str: Current Mode
"""
state = None
final = None
device_name = device.ha_name

try:
data = self.session.data.products[device.hive_id]
state = data["state"]["mode"]
if state == "BOOST":
state = self._get_product_state(device, "props", "previous", "mode")
final = HIVETOHA[self.heating_type].get(state, state)
except KeyError as e:
_LOGGER.error("get_mode - KeyError getting mode for %s: %s", device_name, e)

return final
state = self._get_product_state(device, "state", "mode")
if state is None:
_LOGGER.debug("get_mode - No mode in state for %s.", device.ha_name)
return None
if state == "BOOST":
state = self._get_product_state(device, "props", "previous", "mode")
return HIVETOHA[self.heating_type].get(state, state)

async def get_state(self, device: Device):
"""Get heating current state.
Expand Down
21 changes: 7 additions & 14 deletions src/devices/hotwater.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,13 @@ async def get_mode(self, device: Device):
Returns:
str: Return mode.
"""
state = None
final = None
device_name = device.ha_name

try:
data = self.session.data.products[device.hive_id]
state = data["state"]["mode"]
if state == "BOOST":
state = self._get_product_state(device, "props", "previous", "mode")
final = HIVETOHA[self.hotwater_type].get(state, state)
except KeyError as e:
_LOGGER.error("get_mode - KeyError getting mode for %s: %s", device_name, e)

return final
state = self._get_product_state(device, "state", "mode")
if state is None:
_LOGGER.debug("get_mode - No mode in state for %s.", device.ha_name)
return None
if state == "BOOST":
state = self._get_product_state(device, "props", "previous", "mode")
return HIVETOHA[self.hotwater_type].get(state, state)

@staticmethod
async def get_operation_modes():
Expand Down
1 change: 1 addition & 0 deletions src/session/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def __init__(
self.hub_id = None
self._last_poll_slow = False
self._slow_poll_threshold = 3
self._stale_state_polls: dict[str, int] = {}
self._refresh_threshold = 0.90
self._update_task: asyncio.Task | None = None

Expand Down
79 changes: 77 additions & 2 deletions src/session/polling.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,24 @@

_LOGGER = logging.getLogger(__name__)

# Consecutive polls for which state keys missing from a response are carried
# over from the previous poll before the response is accepted as it is.
MAX_STALE_STATE_POLLS = 5

# State keys Hive always sends for a product that has them. Only these are
# carried over: other keys (``boost``, the colour keys) are legitimately
# omitted at times, so keeping their old value would report stale state.
REQUIRED_STATE_KEYS = frozenset({"mode", "name", "status", "target"})


class PollingMixin:
"""Device polling, rate-limiting, and entity-cache methods.

Expects ``self.config``, ``self.tokens``, ``self.api``,
``self.update_lock``, ``self._update_task``,
``self._last_poll_slow``, and ``self._slow_poll_threshold``
to be set up by the owning class's ``__init__``.
``self._last_poll_slow``, ``self._slow_poll_threshold``, and
``self._stale_state_polls`` to be set up by the owning class's
``__init__``.
"""

# Attributes provided by HiveSession.__init__
Expand All @@ -35,6 +45,7 @@ class PollingMixin:
_update_task: asyncio.Task | None
_last_poll_slow: bool
_slow_poll_threshold: int
_stale_state_polls: dict[str, int]

@staticmethod
def _entity_cache_key(device) -> str:
Expand Down Expand Up @@ -70,6 +81,69 @@ def should_use_cached_data(self):
return self._update_task is None or current_task is not self._update_task
return False

def _keep_missing_state(self, new_products: dict) -> None:
"""Carry over required state keys that a poll response has dropped.

Hive sometimes answers a poll with products whose ``state`` block is
missing keys it normally has. Keep the last known value for the keys
in ``REQUIRED_STATE_KEYS``, for up to ``MAX_STALE_STATE_POLLS``
consecutive polls.

Args:
new_products (dict): Products from the latest response, keyed by ID.
"""
incomplete = []
for product_id, product in new_products.items():
previous = self.data.products.get(product_id) or {}
previous_state = previous.get("state")
new_state = product.get("state")
if not isinstance(new_state, dict):
new_state = {}
missing: list[str] = []
if isinstance(previous_state, dict):
missing = sorted(
(previous_state.keys() & REQUIRED_STATE_KEYS) - new_state.keys()
)
if not isinstance(previous_state, dict) or not missing:
self._stale_state_polls.pop(product_id, None)
continue
name = previous_state.get("name", product_id)
product_type = product.get("type")
_LOGGER.debug(
"Hive API state for %s %s arrived with keys: %s",
product_type,
product_id,
", ".join(sorted(new_state)),
)
polls = self._stale_state_polls.get(product_id, 0) + 1
self._stale_state_polls[product_id] = polls
if polls > MAX_STALE_STATE_POLLS:
_LOGGER.warning(
"Hive API state for %s (%s %s) is still missing %s after %d "
"polls — no longer keeping the last known values.",
name,
product_type,
product_id,
", ".join(missing),
MAX_STALE_STATE_POLLS,
)
continue
product["state"] = {
**{key: previous_state[key] for key in missing},
**new_state,
}
incomplete.append(
f"{name} ({product_type} {product_id}) missing {', '.join(missing)}"
)
for product_id in self._stale_state_polls.keys() - new_products.keys():
del self._stale_state_polls[product_id]
if incomplete:
_LOGGER.warning(
"Hive API returned incomplete state — keeping the last known "
"values: %s",
"; ".join(sorted(incomplete)),
Comment thread
KJonline marked this conversation as resolved.
)

async def _poll_devices(self) -> bool:
"""Fetch latest device state from the Hive API."""
return await self.get_devices("No_ID")
Expand Down Expand Up @@ -205,6 +279,7 @@ async def get_devices(self, _n_id: str): # pylint: disable=too-many-locals,too-
len(tmp_actions),
)
if tmp_products:
self._keep_missing_state(tmp_products)
self.data.products = tmp_products
if tmp_devices:
self.data.devices = tmp_devices
Expand Down
1 change: 1 addition & 0 deletions tests/module/test_session_polling.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class StubPolling(PollingMixin):
p._update_task = None
p._last_poll_slow = False
p._slow_poll_threshold = 3
p._stale_state_polls = {}
p._poll_devices = AsyncMock(return_value=True)
return p

Expand Down
25 changes: 25 additions & 0 deletions tests/unit/test_heating.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,3 +493,28 @@ async def test_cached_none_falls_through_to_normal_path(self):
result = await climate.get_climate(_make_device())
assert result is not None
climate.session.attr.online_offline.assert_called_once()


# ---------------------------------------------------------------------------
# get_mode — state block present but mode missing (incomplete API response)
# ---------------------------------------------------------------------------


class TestGetModeMissingMode:
"""get_mode must not log an error per call when state has no mode."""

async def test_missing_mode_returns_none_without_error_log(self):
"""state without 'mode' returns None and logs nothing at ERROR."""
climate = _make_climate({"heat-1": {"state": {"target": 7}}})
with patch("apyhiveapi.devices.heating._LOGGER") as mock_log:
result = await climate.get_mode(_make_device())
assert result is None
mock_log.error.assert_not_called()

async def test_schedule_lookup_with_missing_mode_returns_none(self):
"""get_schedule_now_next_later returns None quietly when mode is missing."""
climate = _make_climate({"heat-1": {"state": {"schedule": {}}}})
with patch("apyhiveapi.devices.heating._LOGGER") as mock_log:
result = await climate.get_schedule_now_next_later(_make_device())
assert result is None
mock_log.error.assert_not_called()
14 changes: 14 additions & 0 deletions tests/unit/test_hotwater.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,3 +326,17 @@ async def test_cached_none_falls_through(self):
result = await hw.get_water_heater(_make_device())
assert result is not None
hw.session.attr.online_offline.assert_called_once()


class TestHotwaterGetModeMissingMode:
"""get_mode must not log an error per call when state has no mode."""

async def test_missing_mode_returns_none_without_error_log(self):
"""state without 'mode' returns None and logs nothing at ERROR."""
from unittest.mock import patch

hw = _make_hotwater({"hw-1": {"state": {"status": "OFF"}}})
with patch("apyhiveapi.devices.hotwater._LOGGER") as mock_log:
result = await hw.get_mode(_make_device())
assert result is None
mock_log.error.assert_not_called()
1 change: 1 addition & 0 deletions tests/unit/test_polling.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class StubPolling(PollingMixin):
p._update_task = None
p._last_poll_slow = False
p._slow_poll_threshold = 3
p._stale_state_polls = {}
return p


Expand Down
Loading
Loading