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
52 changes: 52 additions & 0 deletions src/devices/heating.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from ..helper.compat_aliases import HeatingCompatMixin
from ..helper.const import HIVETOHA
from ..helper.device_handler_base import BaseDeviceHandler
from ..helper.hive_exceptions import HiveApiError
from ..helper.hivedataclasses import Device
from .boost import BoostMixin

Expand Down Expand Up @@ -48,6 +49,57 @@ 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 or the request fails.

Hive averages readings within each bucket. The endpoint is only
known to work for ``heating`` zones; TRV zones return 404.
"""
if self.session.config.file:
return []
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".

device.hive_id, start, end, time_unit, rate
)
except HiveApiError:
_LOGGER.warning(
"get_temperature_history - Hive returned no history for %s",
device.ha_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
90 changes: 90 additions & 0 deletions tests/module/test_temperature_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Tests for Climate.get_temperature_history and the Hive history API call."""

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

from apyhiveapi.devices.heating import Climate
from apyhiveapi.helper.hive_exceptions import HiveApiError
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, raise_api_error=False):
session = MagicMock()
session.data = Map(
{"products": {"heat-1": {"type": "heating", "state": {}}}, "devices": {}}
)
session.config = SessionConfig()
session.config.file = file_mode
session.api = MagicMock()
if raise_api_error:
session.api.get_heating_history = AsyncMock(side_effect=HiveApiError)
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="heating",
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) == []

async def test_empty_on_api_error(self):
climate, device, _ = _make_climate(raise_api_error=True)
assert await climate.get_temperature_history(device, 1, 2) == []

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