From 6701de91364054a7335a76a8414e34c93dfad8d9 Mon Sep 17 00:00:00 2001 From: Matt Dennis Date: Tue, 20 Oct 2026 16:00:04 +0100 Subject: [PATCH] feat: add thermostat temperature history Adds get_heating_history to both API layers (async and sync), calling /history/heating/ with start/end in ms since epoch, timeUnit and rate, always with operation=AVG (the only value Hive accepts). Adds Climate.get_temperature_history(device, start, end, time_unit, rate), which returns [{"date": , "temperature": }, ...] sorted by date, with int-cast dates (Hive returns them as strings). Returns [] in file mode, on HiveApiError (Hive answers 404 for zones with no history, including TRVs) and for malformed responses. camelCase alias getTemperatureHistory included. Verified live against a heating zone: 24 hourly rows over a day and 7 daily rows over a week. TRV zones return 404 as noted in the issue. Closes #34 Co-Authored-By: Claude Fable 5.1 --- README.md | 6 +- src/api/hive_api.py | 30 ++++++++ src/api/hive_async_api.py | 21 ++++++ src/devices/heating.py | 52 ++++++++++++++ src/helper/compat_aliases.py | 13 ++++ tests/module/test_temperature_history.py | 90 ++++++++++++++++++++++++ tests/unit/test_hive_api.py | 25 +++++++ tests/unit/test_hive_async_api.py | 31 ++++++++ 8 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 tests/module/test_temperature_history.py diff --git a/README.md b/README.md index 97c310dc..1d4f0891 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -112,6 +112,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": , "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) diff --git a/src/api/hive_api.py b/src/api/hive_api.py index 0bf1ba76..98763995 100644 --- a/src/api/hive_api.py +++ b/src/api/hive_api.py @@ -158,6 +158,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": "", "temperature": }, ...]}``. + """ + 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 = ( diff --git a/src/api/hive_async_api.py b/src/api/hive_async_api.py index 9747a685..7ac7ceb4 100644 --- a/src/api/hive_async_api.py +++ b/src/api/hive_async_api.py @@ -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": "", "temperature": }, ...]}``. + """ + 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 diff --git a/src/devices/heating.py b/src/devices/heating.py index 934aa562..366e371c 100644 --- a/src/devices/heating.py +++ b/src/devices/heating.py @@ -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 @@ -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": , "temperature": }`` + 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( + 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())) diff --git a/src/helper/compat_aliases.py b/src/helper/compat_aliases.py index 4c8ba48c..2ba29102 100644 --- a/src/helper/compat_aliases.py +++ b/src/helper/compat_aliases.py @@ -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.""" diff --git a/tests/module/test_temperature_history.py b/tests/module/test_temperature_history.py new file mode 100644 index 00000000..55b32903 --- /dev/null +++ b/tests/module/test_temperature_history.py @@ -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 + ) diff --git a/tests/unit/test_hive_api.py b/tests/unit/test_hive_api.py index 4511c104..14cbea30 100644 --- a/tests/unit/test_hive_api.py +++ b/tests/unit/test_hive_api.py @@ -403,6 +403,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() diff --git a/tests/unit/test_hive_async_api.py b/tests/unit/test_hive_async_api.py index 5dc878ff..b0ac62f5 100644 --- a/tests/unit/test_hive_async_api.py +++ b/tests/unit/test_hive_async_api.py @@ -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 # ---------------------------------------------------------------------------