diff --git a/README.md b/README.md index 77324640..4b15271a 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,12 @@ 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) +# Weekly schedule (heating and hot water). Hive only accepts the whole +# week, so edit the dict from get_schedule and pass it back. +schedule = await hive.heating.get_schedule(device) +schedule["monday"][0]["value"]["target"] = 19.5 # heating slots: {"target": °C} +await hive.heating.set_schedule(device, schedule) # hot water slots: {"status": "ON"|"OFF"} + # Hot water await hive.hotwater.set_mode(device, "ON") await hive.hotwater.set_boost_on(device, mins=60) diff --git a/examples/example_set_schedule.py b/examples/example_set_schedule.py new file mode 100644 index 00000000..c8835412 --- /dev/null +++ b/examples/example_set_schedule.py @@ -0,0 +1,100 @@ +"""Round-trip a heating zone's weekly schedule through the Hive API. + +Reads the zone's current schedule, nudges one slot's target by 0.5°C, posts it, +confirms Hive stored it, then restores the original. Nothing is changed +permanently. Requires a saved ``hive_device.json`` from ``example_login.py``. + +Usage:: + + python examples/example_set_schedule.py # first climate zone + python examples/example_set_schedule.py "Office" # zone by name +""" + +from __future__ import annotations + +import asyncio +import copy +import getpass +import json +import os +import sys +from pathlib import Path + +from apyhiveapi import Auth, Hive + +DEVICE_FILE = Path(__file__).resolve().parent.parent / "hive_device.json" + + +async def login(username: str, password: str, device_data: list) -> dict: + """Device login (no SMS) and return the start_session config.""" + auth = Auth(username, password, *device_data) + result = await auth.login() + if result.get("ChallengeName") == Auth.DEVICE_VERIFIER_CHALLENGE: + result = await auth.device_login() + auth_result = result["AuthenticationResult"] + return { + "username": username, + "password": password, + "device_data": device_data, + "tokens": { + "token": auth_result["IdToken"], + "refreshToken": auth_result["RefreshToken"], + "accessToken": auth_result["AccessToken"], + }, + } + + +async def main() -> int: + """Run the round trip and return a process exit code.""" + if not DEVICE_FILE.exists(): + print(f"No {DEVICE_FILE}; run examples/example_login.py first.") + return 1 + saved = json.loads(DEVICE_FILE.read_text(encoding="utf-8")) + password = os.environ.get("HIVE_PASSWORD") or getpass.getpass( + "Hive password (hidden, not stored): " + ) + wanted = sys.argv[1] if len(sys.argv) > 1 else None + + config = await login(saved["username"], password, saved["device_data"]) + async with Hive(username=saved["username"], password=password) as hive: + device_list = await hive.start_session(config) + zones = device_list["climate"] + zone = ( + next((z for z in zones if z.ha_name == wanted), None) + if wanted + else zones[0] + ) + if zone is None: + print( + f"No climate zone called {wanted!r}. Have: {[z.ha_name for z in zones]}" + ) + return 1 + + original = await hive.heating.get_schedule(zone) + if original is None: + print(f"{zone.ha_name} has no schedule in its product state.") + return 1 + print(f"\n{zone.ha_name} ({zone.hive_type}) current monday:") + print(" ", json.dumps(original["monday"])) + + changed = copy.deepcopy(original) + slot = changed["monday"][0] + slot["value"]["target"] = round(slot["value"]["target"] + 0.5, 1) + print(f"\nSetting monday slot 0 target to {slot['value']['target']} ...") + ok = await hive.heating.set_schedule(zone, changed) + print(" set_schedule returned", ok) + stored = await hive.heating.get_schedule(zone) + print(" stored monday now:", json.dumps(stored["monday"])) + print(" matches what we sent:", stored == changed) + + print("\nRestoring original schedule ...") + ok_restore = await hive.heating.set_schedule(zone, original) + print(" set_schedule returned", ok_restore) + restored = await hive.heating.get_schedule(zone) + print(" restored matches original:", restored == original) + + return 0 if ok and ok_restore and restored == original else 2 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/src/devices/heating.py b/src/devices/heating.py index 934aa562..82cacde7 100644 --- a/src/devices/heating.py +++ b/src/devices/heating.py @@ -9,11 +9,12 @@ from ..helper.device_handler_base import BaseDeviceHandler from ..helper.hivedataclasses import Device from .boost import BoostMixin +from .schedule import ScheduleMixin _LOGGER = logging.getLogger(__name__) -class HiveHeating(BoostMixin, BaseDeviceHandler): +class HiveHeating(ScheduleMixin, BoostMixin, BaseDeviceHandler): """Hive Heating Code. Returns: diff --git a/src/devices/hotwater.py b/src/devices/hotwater.py index b36ca8d0..14d90543 100644 --- a/src/devices/hotwater.py +++ b/src/devices/hotwater.py @@ -8,11 +8,12 @@ from ..helper.device_handler_base import BaseDeviceHandler from ..helper.hivedataclasses import Device from .boost import BoostMixin +from .schedule import ScheduleMixin _LOGGER = logging.getLogger(__name__) -class HiveHotwater(BoostMixin, BaseDeviceHandler): +class HiveHotwater(ScheduleMixin, BoostMixin, BaseDeviceHandler): """Hive Hotwater Code. Returns: diff --git a/src/devices/schedule.py b/src/devices/schedule.py new file mode 100644 index 00000000..7a5d6983 --- /dev/null +++ b/src/devices/schedule.py @@ -0,0 +1,166 @@ +"""Shared weekly-schedule helpers for heating and hot water.""" + +from __future__ import annotations + +import logging +from typing import Any + +from ..helper.hivedataclasses import Device + +_LOGGER = logging.getLogger(__name__) + +DAYS = ( + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", +) +_MINUTES_PER_DAY = 1440 + +# Which key each product type expects inside a slot's ``value`` dict. +_VALUE_KEY = { + "heating": "target", + "trvcontrol": "target", + "hotwater": "status", +} + + +class ScheduleMixin: + """Read and write the full 7-day schedule for heating and hot water. + + The Hive API stores a product's schedule as a dict keyed by lowercase + weekday name. Each day is a list of slots ``{"start": , "value": {...}}``. Heating slots carry ``{"target": <°C>}``; + hot water slots carry ``{"status": "ON" | "OFF"}``. + + Hive only accepts the whole week at once, so ``set_schedule`` always + posts all seven days. Build the new schedule by editing the dict returned + from ``get_schedule`` and pass it back. + + Expects ``self.session`` and the ``BaseDeviceHandler`` plumbing to be + provided by the owning class. + """ + + session: Any + + async def get_schedule(self, device: Device) -> dict | None: + """Return the product's full weekly schedule, or None if unavailable. + + The dict is a deep copy, so it can be edited and passed straight to + ``set_schedule``. + """ + schedule = self._get_product_state( # type: ignore[attr-defined] + device, "state", "schedule" + ) + if not isinstance(schedule, dict): + return None + return { + day: [ + {"start": slot["start"], "value": dict(slot["value"])} for slot in slots + ] + for day, slots in schedule.items() + } + + async def set_schedule(self, device: Device, schedule: dict) -> bool: + """Replace the product's weekly schedule. + + Args: + device: Device whose schedule to set. + schedule: Dict keyed by the seven lowercase weekday names, each a + list of ``{"start": int, "value": dict}`` slots. Slots are + normalised (extra keys dropped, sorted by start) before + posting. + + Returns: + True if Hive accepted the schedule and the refreshed product + state matches what was sent; False otherwise. + + Raises: + ValueError: If ``schedule`` is malformed. This is a caller bug, + not an API failure, so it is raised rather than returned. + """ + product_type = self._get_product_state( # type: ignore[attr-defined] + device, "type" + ) + value_key = _VALUE_KEY.get(product_type) + if value_key is None: + _LOGGER.error( + "set_schedule - %s (%s) does not support schedules", + device.ha_name, + product_type, + ) + return False + + normalised = self._normalise_schedule(schedule, value_key) + _LOGGER.info("set_schedule - Setting weekly schedule for %s", device.ha_name) + changed = await self._execute_state_change( # type: ignore[attr-defined] + device, schedule=normalised + ) + if not changed: + return False + + # Hive answers 200 and echoes the payload even when it ignores it, so + # confirm the refreshed product state actually carries the new schedule. + stored = await self.get_schedule(device) + if stored != normalised: + _LOGGER.error( + "set_schedule - Hive accepted the request for %s but the stored " + "schedule does not match what was sent", + device.ha_name, + ) + return False + return True + + @staticmethod + def _normalise_schedule(schedule: dict, value_key: str) -> dict: + """Validate a schedule and return a clean copy ready to post.""" + if not isinstance(schedule, dict): + raise ValueError("schedule must be a dict keyed by weekday name") + missing = [day for day in DAYS if day not in schedule] + unknown = [day for day in schedule if day not in DAYS] + if missing or unknown: + raise ValueError( + f"schedule must contain exactly the days {list(DAYS)}; " + f"missing={missing} unknown={unknown}" + ) + + clean: dict = {} + for day in DAYS: + slots = schedule[day] + if not isinstance(slots, list) or not slots: + raise ValueError(f"{day}: expected a non-empty list of slots") + day_slots = [] + for slot in slots: + day_slots.append(ScheduleMixin._normalise_slot(day, slot, value_key)) + day_slots.sort(key=lambda s: s["start"]) + starts = [s["start"] for s in day_slots] + if len(set(starts)) != len(starts): + raise ValueError(f"{day}: duplicate slot start times {starts}") + clean[day] = day_slots + return clean + + @staticmethod + def _normalise_slot(day: str, slot: Any, value_key: str) -> dict: + """Validate one slot and return ``{"start": int, "value": {...}}``.""" + if not isinstance(slot, dict): + raise ValueError(f"{day}: each slot must be a dict, got {slot!r}") + start = slot.get("start") + if ( + isinstance(start, bool) + or not isinstance(start, int) + or not 0 <= start < _MINUTES_PER_DAY + ): + raise ValueError( + f"{day}: slot start must be an int in 0..{_MINUTES_PER_DAY - 1} " + f"(minutes past midnight), got {start!r}" + ) + value = slot.get("value") + if not isinstance(value, dict) or value_key not in value: + raise ValueError( + f"{day}: slot at {start} needs a value dict containing " + f"{value_key!r}, got {value!r}" + ) + return {"start": start, "value": {value_key: value[value_key]}} diff --git a/src/helper/compat_aliases.py b/src/helper/compat_aliases.py index 4c8ba48c..fd57667d 100644 --- a/src/helper/compat_aliases.py +++ b/src/helper/compat_aliases.py @@ -60,6 +60,14 @@ 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 getSchedule(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_schedule.""" + return await self.get_schedule(device) # type: ignore[attr-defined] + + async def setSchedule(self, device: Device, schedule: dict): # pylint: disable=invalid-name + """Backwards-compatible alias for set_schedule.""" + return await self.set_schedule(device, schedule) # type: ignore[attr-defined] + class LightCompatMixin: """CamelCase aliases for Light public methods.""" @@ -116,6 +124,14 @@ 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 getSchedule(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_schedule.""" + return await self.get_schedule(device) # type: ignore[attr-defined] + + async def setSchedule(self, device: Device, schedule: dict): # pylint: disable=invalid-name + """Backwards-compatible alias for set_schedule.""" + return await self.set_schedule(device, schedule) # type: ignore[attr-defined] + async def setMode(self, device: Device, new_mode: str): # pylint: disable=invalid-name """Backwards-compatible alias for set_mode.""" return await self.set_mode(device, new_mode) # type: ignore[attr-defined] diff --git a/tests/module/test_schedule.py b/tests/module/test_schedule.py new file mode 100644 index 00000000..d30f04f3 --- /dev/null +++ b/tests/module/test_schedule.py @@ -0,0 +1,297 @@ +"""Tests for ScheduleMixin — weekly schedule get/set shared by heating and hot water.""" + +# pylint: disable=too-few-public-methods +import copy +from unittest.mock import AsyncMock, MagicMock + +import pytest +from apyhiveapi.devices.heating import Climate +from apyhiveapi.devices.hotwater import WaterHeater +from apyhiveapi.devices.schedule import DAYS +from apyhiveapi.helper.hivedataclasses import Device, SessionConfig +from apyhiveapi.helper.map import Map + +_HTTP_OK = 200 +_HTTP_BAD = 400 +_NEW_TARGET = 19.5 + + +def _heating_week(target_a=20, target_b=7): + """A minimal valid heating schedule with two slots per day.""" + return { + day: [ + {"start": 420, "value": {"target": target_a}}, + {"start": 1320, "value": {"target": target_b}}, + ] + for day in DAYS + } + + +def _hotwater_week(): + """A minimal valid hot water schedule with two slots per day.""" + return { + day: [ + {"start": 405, "value": {"status": "ON"}}, + {"start": 435, "value": {"status": "OFF"}}, + ] + for day in DAYS + } + + +def _make_session(products): + session = MagicMock() + session.data = Map( + {"products": products, "devices": {}, "actions": {}, "minMax": {}, "user": {}} + ) + session.config = SessionConfig() + session.helper = MagicMock() + session.attr = MagicMock() + session.attr.online_offline = AsyncMock(return_value=True) + session.api = MagicMock() + session.api.set_state = AsyncMock(return_value={"original": _HTTP_OK, "parsed": {}}) + session.hive_refresh_tokens = AsyncMock() + session.get_devices = AsyncMock(return_value=True) + return session + + +def _make_climate(schedule=None, product_type="heating"): + """Climate whose product carries *schedule*; get_devices leaves it unchanged.""" + products = {"heat-1": {"type": product_type, "state": {}}} + if schedule is not None: + products["heat-1"]["state"]["schedule"] = schedule + return Climate(session=_make_session(products)) + + +def _make_hotwater(schedule=None): + products = {"hw-1": {"type": "hotwater", "state": {"schedule": schedule}}} + return WaterHeater(session=_make_session(products)) + + +def _device(hive_id="heat-1", ha_type="climate", online=True): + return Device( + hive_id=hive_id, + hive_name="Zone", + hive_type="heating", + ha_type=ha_type, + device_id="dev-1", + device_name="Zone", + device_data={"online": online}, + ha_name="Zone", + ) + + +def _simulate_hive_storing(climate, hive_id="heat-1"): + """Make get_devices copy the posted schedule into the product, as Hive does.""" + + async def _store(_id): + _, kwargs = climate.session.api.set_state.call_args + climate.session.data.products[hive_id]["state"]["schedule"] = copy.deepcopy( + kwargs["schedule"] + ) + return True + + climate.session.get_devices = AsyncMock(side_effect=_store) + + +class TestGetSchedule: + """get_schedule returns an editable deep copy of the stored week.""" + + async def test_returns_copy_of_stored_schedule(self): + """The returned dict equals the stored one but shares no slot objects.""" + week = _heating_week() + climate = _make_climate(week) + result = await climate.get_schedule(_device()) + assert result == week + result["monday"][0]["value"]["target"] = 99 + assert week["monday"][0]["value"]["target"] != 99 # noqa: PLR2004 + + async def test_returns_none_when_missing(self): + """No schedule in product state yields None rather than an error.""" + climate = _make_climate() + assert await climate.get_schedule(_device()) is None + + async def test_returns_none_for_unknown_product(self): + """A device not present in session.data.products yields None.""" + climate = _make_climate(_heating_week()) + assert await climate.get_schedule(_device(hive_id="nope")) is None + + +class TestSetScheduleHeating: + """set_schedule posts the whole normalised week and verifies it stuck.""" + + async def test_posts_full_week_and_returns_true(self): + """Happy path: set_state gets schedule=, stored copy matches.""" + climate = _make_climate(_heating_week()) + _simulate_hive_storing(climate) + new = _heating_week() + new["monday"][0]["value"]["target"] = _NEW_TARGET + + assert await climate.set_schedule(_device(), new) is True + + climate.session.api.set_state.assert_called_once() + args, kwargs = climate.session.api.set_state.call_args + assert args == ("heating", "heat-1") + assert list(kwargs) == ["schedule"] + assert kwargs["schedule"]["monday"][0]["value"]["target"] == _NEW_TARGET + assert set(kwargs["schedule"]) == set(DAYS) + + async def test_strips_extra_slot_keys_and_sorts(self): + """Keys the Hive web app adds (type/dayIndex/eventIndex) are dropped.""" + climate = _make_climate(_heating_week()) + _simulate_hive_storing(climate) + new = _heating_week() + new["monday"] = [ + { + "start": 1320, + "value": {"target": 7, "junk": 1}, + "type": "HEATING", + "dayIndex": 0, + "eventIndex": 1, + }, + {"start": 420, "value": {"target": 20}}, + ] + + assert await climate.set_schedule(_device(), new) is True + _, kwargs = climate.session.api.set_state.call_args + assert kwargs["schedule"]["monday"] == [ + {"start": 420, "value": {"target": 20}}, + {"start": 1320, "value": {"target": 7}}, + ] + + async def test_trvcontrol_supported(self): + """TRV zones use the same target-based slots as heating.""" + climate = _make_climate(_heating_week(), product_type="trvcontrol") + _simulate_hive_storing(climate) + assert await climate.set_schedule(_device(), _heating_week()) is True + + async def test_returns_false_when_api_fails(self): + """A non-200 from set_state propagates as False with no verify step.""" + climate = _make_climate(_heating_week()) + climate.session.api.set_state = AsyncMock( + return_value={"original": _HTTP_BAD, "parsed": {}} + ) + assert await climate.set_schedule(_device(), _heating_week(9, 9)) is False + climate.session.get_devices.assert_not_called() + + async def test_returns_false_when_offline(self): + """Offline devices short-circuit before any API call.""" + climate = _make_climate(_heating_week()) + assert ( + await climate.set_schedule(_device(online=False), _heating_week()) is False + ) + climate.session.api.set_state.assert_not_called() + + async def test_returns_false_when_hive_silently_ignores(self): + """Hive answers 200 but the refreshed product still has the old week.""" + climate = _make_climate(_heating_week()) # get_devices is a no-op + assert await climate.set_schedule(_device(), _heating_week(9, 9)) is False + climate.session.api.set_state.assert_called_once() + + async def test_returns_false_for_unsupported_product_type(self): + """Products with no schedule concept are refused without an API call.""" + climate = _make_climate(_heating_week(), product_type="activeplug") + assert await climate.set_schedule(_device(), _heating_week()) is False + climate.session.api.set_state.assert_not_called() + + async def test_returns_false_for_unknown_product(self): + """A device absent from session.data.products is refused.""" + climate = _make_climate(_heating_week()) + assert ( + await climate.set_schedule(_device(hive_id="nope"), _heating_week()) + is False + ) + + +class TestSetScheduleHotwater: + """Hot water slots use {"status": "ON"|"OFF"} values.""" + + async def test_posts_status_slots(self): + """Hot water schedule is posted under the hotwater product type.""" + heater = _make_hotwater(_hotwater_week()) + _simulate_hive_storing(heater, hive_id="hw-1") + new = _hotwater_week() + new["sunday"][0]["value"]["status"] = "OFF" + dev = _device(hive_id="hw-1", ha_type="water_heater") + + assert await heater.set_schedule(dev, new) is True + args, kwargs = heater.session.api.set_state.call_args + assert args == ("hotwater", "hw-1") + assert kwargs["schedule"]["sunday"][0]["value"] == {"status": "OFF"} + + async def test_rejects_heating_style_slots(self): + """A target-based slot on a hot water product is a caller error.""" + heater = _make_hotwater(_hotwater_week()) + dev = _device(hive_id="hw-1", ha_type="water_heater") + with pytest.raises(ValueError, match="'status'"): + await heater.set_schedule(dev, _heating_week()) + + +class TestSetScheduleValidation: + """Malformed schedules raise ValueError before anything is posted.""" + + @pytest.mark.parametrize( + ("mutate", "match"), + [ + (lambda w: w.pop("sunday"), "missing=\\['sunday'\\]"), + (lambda w: w.update(funday=[]), "unknown=\\['funday'\\]"), + (lambda w: w.__setitem__("monday", []), "non-empty list"), + (lambda w: w.__setitem__("monday", "0900"), "non-empty list"), + (lambda w: w.__setitem__("monday", ["slot"]), "must be a dict"), + (lambda w: w["monday"][0].__setitem__("start", 1440), "0..1439"), + (lambda w: w["monday"][0].__setitem__("start", -1), "0..1439"), + (lambda w: w["monday"][0].__setitem__("start", "420"), "0..1439"), + (lambda w: w["monday"][0].__setitem__("start", True), "0..1439"), + (lambda w: w["monday"][0].__setitem__("value", {"temp": 20}), "'target'"), + (lambda w: w["monday"][0].__setitem__("value", 20), "'target'"), + (lambda w: w["monday"][1].__setitem__("start", 420), "duplicate"), + ], + ) + async def test_rejects_malformed(self, mutate, match): + """Each mutation produces a ValueError naming the problem.""" + climate = _make_climate(_heating_week()) + week = _heating_week() + mutate(week) + with pytest.raises(ValueError, match=match): + await climate.set_schedule(_device(), week) + climate.session.api.set_state.assert_not_called() + + async def test_rejects_non_dict(self): + """A list instead of a dict is rejected.""" + climate = _make_climate(_heating_week()) + with pytest.raises(ValueError, match="keyed by weekday"): + await climate.set_schedule(_device(), []) + + +class TestCamelCaseAliases: + """getSchedule / setSchedule delegate for HA custom-component compatibility.""" + + async def test_aliases_delegate(self): + """Both aliases exist on Climate and WaterHeater and delegate.""" + for handler in ( + _make_climate(_heating_week()), + _make_hotwater(_hotwater_week()), + ): + handler.get_schedule = AsyncMock(return_value="week") + handler.set_schedule = AsyncMock(return_value=True) + dev = _device() + assert await handler.getSchedule(dev) == "week" + assert await handler.setSchedule(dev, {"x": 1}) is True + handler.set_schedule.assert_called_once_with(dev, {"x": 1}) + + +class TestFileModeRoundTrip: + """The bundled fixture's heating and hot water schedules pass validation.""" + + async def test_fixture_schedules_normalise_cleanly(self): + """get_schedule output from the fixture is accepted by the validator.""" + from apyhiveapi import Hive # pylint: disable=import-outside-toplevel + + async with Hive(username="use@file.com", password="x") as hive: + await hive.start_session({}) + for dev in hive.device_list["climate"]: + week = await hive.heating.get_schedule(dev) + assert set(week) == set(DAYS) + assert hive.heating._normalise_schedule(week, "target") == week # pylint: disable=protected-access + for dev in hive.device_list["water_heater"]: + week = await hive.hotwater.get_schedule(dev) + assert hive.hotwater._normalise_schedule(week, "status") == week # pylint: disable=protected-access