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
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(
Comment thread
KJonline marked this conversation as resolved.
Resolved
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