Skip to content
Open
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ A Python library for interfacing with the [Hive](https://www.hivehome.com/) smar

| Device Type | Capabilities |
| --- | --- |
| **Heating** (thermostat, TRV) | Current / target temperature, mode (schedule / manual / off), boost on/off, heat-on-demand, min/max range, schedule now/next/later |
| **Heating** (thermostat, TRV) | Current / target temperature, mode (schedule / manual / off), boost on/off, heat-on-demand, min/max range, schedule now/next/later, temperature history |
| **Hot Water** | Mode (schedule / on / off), boost on/off, state |
| **Lights** | On/off, brightness, colour temperature, full RGB colour, colour mode |
| **Smart Plugs** | On/off, power usage |
Expand Down Expand Up @@ -125,6 +125,10 @@ await hive.heating.set_mode(device, "SCHEDULE")
await hive.heating.set_boost_on(device, mins=30, temp=22.0)
await hive.heating.set_boost_off(device)

# Temperature history for a heating zone (ms since epoch; HOURS/DAYS/...).
# Returns [{"date": <ms>, "temperature": <°C>}, ...], averaged per bucket.
history = await hive.heating.get_temperature_history(device, start_ms, end_ms, "HOURS")

# Hot water
await hive.hotwater.set_mode(device, "ON")
await hive.hotwater.set_boost_on(device, mins=60)
Expand Down
30 changes: 30 additions & 0 deletions src/api/hive_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,36 @@ def get_actions(self):
url = self.urls["base"] + self.urls["actions"]
return self._call_endpoint("GET", url)

def get_heating_history(
self,
node_id,
start,
end,
time_unit="HOURS",
rate=1,
):
"""Fetch temperature history for a heating product.

``start`` and ``end`` are milliseconds since the Unix epoch. Hive
accepts ``time_unit`` of YEARS, DAYS, HOURS, MINUTES or SECONDS and
returns ``{"data": [{"date": "<ms>", "temperature": <float>}, ...]}``.
"""
url = (
self.urls["base"]
+ "/history/heating/"
+ str(node_id)
+ "?start="
+ str(start)
+ "&end="
+ str(end)
+ "&timeUnit="
+ str(time_unit)
+ "&rate="
+ str(rate)
+ "&operation=AVG"
)
return self._call_endpoint("GET", url)

def motion_sensor(self, sensor, fromepoch, toepoch):
"""Call a way to get motion sensor info."""
url = (
Expand Down
21 changes: 21 additions & 0 deletions src/api/hive_async_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,27 @@ async def motion_sensor(self, sensor, fromepoch, toepoch):
)
return await self._call_endpoint("get", url)

async def get_heating_history(
self,
node_id: str,
start: int,
end: int,
time_unit: str = "HOURS",
rate: int = 1,
):
"""Fetch temperature history for a heating product.

``start`` and ``end`` are milliseconds since the Unix epoch. Hive
accepts ``time_unit`` of YEARS, DAYS, HOURS, MINUTES or SECONDS and
returns ``{"data": [{"date": "<ms>", "temperature": <float>}, ...]}``.
"""
url = (
f"{self.base_url}/history/heating/{node_id}"
f"?start={start}&end={end}&timeUnit={time_unit}"
f"&rate={rate}&operation=AVG"
)
return await self._call_endpoint("get", url)

async def get_weather(self, weather_url):
"""Call endpoint to get local weather from Hive API."""
t_url = self.urls["weather"] + weather_url
Expand Down
64 changes: 64 additions & 0 deletions src/devices/heating.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"""Hive Heating Module."""

import asyncio
import logging
from datetime import datetime
from typing import Any

from ..helper.compat_aliases import HeatingCompatMixin
from ..helper.const import HIVETOHA
from ..helper.device_handler_base import BaseDeviceHandler
from ..helper.hive_exceptions import HiveApiError, HiveAuthError
from ..helper.hivedataclasses import Device
from .boost import BoostMixin

Expand Down Expand Up @@ -48,6 +50,68 @@ async def get_max_temperature(self, device: Device):
return self._get_product_state(device, "props", "maxHeat")
return 32

async def get_temperature_history(
self,
device: Device,
start: int,
end: int,
time_unit: str = "HOURS",
rate: int = 1,
) -> list[dict[str, Any]]:
"""Return recorded temperatures for a heating zone.

Args:
device: The heating device (thermostat zone).
start: Range start, milliseconds since the Unix epoch.
end: Range end, milliseconds since the Unix epoch.
time_unit: YEARS, DAYS, HOURS, MINUTES or SECONDS.
rate: Number of ``time_unit`` per bucket.

Returns:
A list of ``{"date": <ms since epoch, int>, "temperature": <float>}``
sorted by date. Empty if Hive has no data, the request fails or
times out, or the device is a TRV zone.

Raises:
HiveAuthError, NoApiToken: the session's credentials are no longer
valid. These propagate, as they do from ``update_data``, so the
caller can start a re-authentication.

Hive averages readings within each bucket. The endpoint is only
known to work for ``heating`` zones; TRV zones return 404, so they
are skipped without calling Hive.
"""
if self.session.config.file or device.hive_type == "trvcontrol":
return []
await self.session.hive_refresh_tokens()
try:
resp = await self.session.api.get_heating_history(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Claude Code Review

The docstring says this returns an empty list "if … the request fails", but only HiveApiError is caught. HiveApiAsync._call_endpoint re-raises asyncio.TimeoutError. request() raises HiveAuthError on 401/403 and NoApiToken when no token is stored.

Failure scenario: a Hive timeout, or a token that has expired between polls, makes get_temperature_history raise instead of returning []. This method also never refreshes tokens, unlike the _execute_state_change path.

Suggested fix: either catch (HiveApiError, asyncio.TimeoutError) here, or update the docstring to say that timeout and auth errors propagate. If it's meant to be callable on its own, refresh the token first the way the other handlers do.

Minor: for TRV zones the documented 404 is logged at ERROR by request() before this WARNING, so callers get noisy duplicate logs. Consider gating on device.hive_type == "heating".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 223b6b8. Timeouts now return [] as documented, and tokens are refreshed first. TRV zones are skipped before the request, so the 404 no longer double-logs. One correction: HiveAuthError subclasses HiveApiError, so a 401/403 was being swallowed rather than propagating. It's now re-raised on purpose and documented, so Home Assistant can trigger reauth. Nothing in Home Assistant calls this method yet.

device.hive_id, start, end, time_unit, rate
)
except HiveAuthError:
raise
except (HiveApiError, asyncio.TimeoutError) as exc:
_LOGGER.warning(
"get_temperature_history - no history for %s (%s)",
device.ha_name,
type(exc).__name__,
)
return []
parsed = resp.get("parsed") if isinstance(resp, dict) else None
rows = parsed.get("data") if isinstance(parsed, dict) else None
if not isinstance(rows, list):
return []
history = []
for row in rows:
try:
history.append(
{"date": int(row["date"]), "temperature": float(row["temperature"])}
)
except (KeyError, TypeError, ValueError):
_LOGGER.debug("get_temperature_history - skipping row %s", row)
history.sort(key=lambda r: r["date"])
return history

def _track_minmax(self, hive_id: str, temperature: float) -> None:
"""Record today's and since-restart min/max temperatures for a device."""
today = str(datetime.date(datetime.now()))
Expand Down
13 changes: 13 additions & 0 deletions src/helper/compat_aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@ async def getScheduleNowNextLater(self, device: Device): # pylint: disable=inva
"""Backwards-compatible alias for get_schedule_now_next_later."""
return await self.get_schedule_now_next_later(device) # type: ignore[attr-defined]

async def getTemperatureHistory( # pylint: disable=invalid-name
self,
device: Device,
start: int,
end: int,
time_unit: str = "HOURS",
rate: int = 1,
):
"""Backwards-compatible alias for get_temperature_history."""
return await self.get_temperature_history( # type: ignore[attr-defined]
device, start, end, time_unit, rate
)


class LightCompatMixin:
"""CamelCase aliases for Light public methods."""
Expand Down
110 changes: 110 additions & 0 deletions tests/module/test_temperature_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Tests for Climate.get_temperature_history and the Hive history API call."""

# pylint: disable=too-few-public-methods
import asyncio
from unittest.mock import AsyncMock, MagicMock

import pytest
from apyhiveapi.devices.heating import Climate
from apyhiveapi.helper.hive_exceptions import HiveApiError, HiveAuthError
from apyhiveapi.helper.hivedataclasses import Device, SessionConfig
from apyhiveapi.helper.map import Map

_HTTP_OK = 200
_ROWS = [
{"date": "1792425280000", "temperature": 20.9375},
{"date": "1792421680000", "temperature": 21.008695652173913},
]


def _make_climate(parsed=None, file_mode=False, side_effect=None, hive_type="heating"):
session = MagicMock()
session.data = Map(
{"products": {"heat-1": {"type": "heating", "state": {}}}, "devices": {}}
)
session.config = SessionConfig()
session.config.file = file_mode
session.hive_refresh_tokens = AsyncMock()
session.api = MagicMock()
if side_effect is not None:
session.api.get_heating_history = AsyncMock(side_effect=side_effect)
else:
session.api.get_heating_history = AsyncMock(
return_value={"original": _HTTP_OK, "parsed": parsed}
)
device = Device(
hive_id="heat-1",
hive_name="Downstairs",
hive_type=hive_type,
ha_name="Downstairs",
ha_type="climate",
device_id="dev-1",
device_name="Thermostat",
device_data={},
)
return Climate(session), device, session


class TestGetTemperatureHistory:
async def test_returns_sorted_rows_with_int_dates(self):
climate, device, session = _make_climate({"data": _ROWS})
history = await climate.get_temperature_history(device, 1, 2, "HOURS", 1)
assert history == [
{"date": 1792421680000, "temperature": 21.008695652173913},
{"date": 1792425280000, "temperature": 20.9375},
]
session.api.get_heating_history.assert_awaited_once_with(
"heat-1", 1, 2, "HOURS", 1
)

async def test_defaults_to_hourly(self):
climate, device, session = _make_climate({"data": []})
assert await climate.get_temperature_history(device, 1, 2) == []
session.api.get_heating_history.assert_awaited_once_with(
"heat-1", 1, 2, "HOURS", 1
)

async def test_skips_malformed_rows(self):
rows = [{"date": "x", "temperature": 1}, {"temperature": 2}, _ROWS[0]]
climate, device, _ = _make_climate({"data": rows})
history = await climate.get_temperature_history(device, 1, 2)
assert history == [{"date": 1792425280000, "temperature": 20.9375}]

async def test_empty_when_data_missing_or_not_a_list(self):
for parsed in ({}, None, {"data": "nope"}, "garbage"):
climate, device, _ = _make_climate(parsed)
assert await climate.get_temperature_history(device, 1, 2) == []

@pytest.mark.parametrize("error", [HiveApiError, asyncio.TimeoutError])
async def test_empty_on_api_error_or_timeout(self, error):
climate, device, _ = _make_climate(side_effect=error)
assert await climate.get_temperature_history(device, 1, 2) == []

async def test_auth_error_propagates(self):
climate, device, _ = _make_climate(side_effect=HiveAuthError("expired"))
with pytest.raises(HiveAuthError):
await climate.get_temperature_history(device, 1, 2)

async def test_refreshes_tokens_before_request(self):
climate, device, session = _make_climate({"data": []})
await climate.get_temperature_history(device, 1, 2)
session.hive_refresh_tokens.assert_awaited_once()

async def test_trv_zone_skipped_without_calling_api(self):
climate, device, session = _make_climate(hive_type="trvcontrol")
assert await climate.get_temperature_history(device, 1, 2) == []
session.hive_refresh_tokens.assert_not_awaited()
session.api.get_heating_history.assert_not_awaited()

async def test_empty_in_file_mode_without_calling_api(self):
climate, device, session = _make_climate(file_mode=True)
assert await climate.get_temperature_history(device, 1, 2) == []
session.api.get_heating_history.assert_not_awaited()

async def test_camel_case_alias(self):
climate, device, session = _make_climate({"data": _ROWS[:1]})
history = await climate.getTemperatureHistory(device, 1, 2, "DAYS", 2)
assert history == [{"date": 1792425280000, "temperature": 20.9375}]
session.api.get_heating_history.assert_awaited_once_with(
"heat-1", 1, 2, "DAYS", 2
)
25 changes: 25 additions & 0 deletions tests/unit/test_hive_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,31 @@ def test_url_contains_actions_path(self):
# ---------------------------------------------------------------------------


class TestGetHeatingHistory:
def test_builds_url_and_returns_data(self):
api = _make_api()
payload = {"data": [{"date": "1", "temperature": 20.5}]}
mock_resp = _make_mock_response(200, json_data=payload)

with patch.object(api, "request", return_value=mock_resp) as mock_req:
result = api.get_heating_history("node-1", 1000, 2000, "DAYS", 3)

assert result["original"] == 200
assert result["parsed"] == payload
assert mock_req.call_args[0][1] == (
api.urls["base"]
+ "/history/heating/node-1?start=1000&end=2000"
+ "&timeUnit=DAYS&rate=3&operation=AVG"
)

def test_defaults_to_hourly_average(self):
api = _make_api()
mock_resp = _make_mock_response(200, json_data={"data": []})
with patch.object(api, "request", return_value=mock_resp) as mock_req:
api.get_heating_history("node-1", 1, 2)
assert "timeUnit=HOURS&rate=1&operation=AVG" in mock_req.call_args[0][1]


class TestMotionSensor:
def test_builds_url_and_returns_data(self):
api = _make_api()
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/test_hive_async_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,37 @@ async def test_motion_sensor_returns_parsed_json(self):
assert result["parsed"] == payload


# ---------------------------------------------------------------------------
# Tests: HiveApiAsync.get_heating_history
# ---------------------------------------------------------------------------


class TestGetHeatingHistory:
async def test_builds_history_url_with_query(self):
api = _make_api(status=200, json_data={"data": []})
captured = {}
original_request = api.request

async def capture_request(method, url, **kwargs):
captured["url"] = url
return await original_request(method, url, **kwargs)

api.request = capture_request
await api.get_heating_history("node-1", 1000, 2000, "DAYS", 3)
assert captured["url"] == (
api.base_url
+ "/history/heating/node-1?start=1000&end=2000"
+ "&timeUnit=DAYS&rate=3&operation=AVG"
)

async def test_defaults_and_returns_parsed_json(self):
payload = {"data": [{"date": "1", "temperature": 20.5}]}
api = _make_api(status=200, json_data=payload)
result = await api.get_heating_history("node-1", 1, 2)
assert result["original"] == 200
assert result["parsed"] == payload


# ---------------------------------------------------------------------------
# Tests: HiveApiAsync.error
# ---------------------------------------------------------------------------
Expand Down
Loading