From 8a0c36be02594c73f6e13c00e62dcb4da610e56f Mon Sep 17 00:00:00 2001 From: Neil Sleightholm Date: Sat, 11 Jul 2026 16:27:33 +0000 Subject: [PATCH 1/2] Restore missing camelCase getter aliases for HA core compatibility The camelCase -> snake_case rename left compat_aliases.py covering only setters (setMode, setBoostOn, ...) plus getClimate/getLight/etc. Home Assistant core's built-in hive sensor/binary_sensor platforms are written against the old camelCase API and call getter methods directly (e.g. hive.heating.getMode(device)) to populate diagnostic entities such as Mode, State, and Boost sensors, and the hub's Smoke/Dog Bark/Glass Break binary_sensors. Those calls now raise AttributeError since the methods only exist as get_mode()/get_state()/etc, so the entities silently stop updating and go unavailable after the object holding the last cached state is dropped (e.g. on next HA restart). Current/Target Temperature sensors were unaffected because core reads those from the pre-computed device.status dict rather than calling a getter directly. Also fixes an existing gap: WaterHeaterCompatMixin only offered the snake_case get_boost (still used by the Hotwater_Boost sensor), not the camelCase getBoost that HA core calls, so it now provides both. Changes: - HeatingCompatMixin: add getMinTemperature, getMaxTemperature, getCurrentTemperature, getTargetTemperature, getMode, getState, getCurrentOperation, getBoostStatus, getBoostTime, getHeatOnDemand, setHeatOnDemand, getOperationModes, getScheduleNowNextLater, minmaxTemperature - WaterHeaterCompatMixin: add getBoost alongside get_boost (bugfix), getBoostTime, getMode, getState, getOperationModes, getScheduleNowNextLater - LightCompatMixin: add getState, getBrightness, getMinColorTemp, getMaxColorTemp, getColorTemp, getColor, getColorMode - SwitchCompatMixin: add getState, getPowerUsage, getSwitchState - SensorCompatMixin: add getState - ActionCompatMixin: add getState - New HubCompatMixin (getSmokeStatus, getDogBarkStatus, getGlassBreakStatus), wired into HiveHub in devices/hub.py Verified in a Home Assistant instance: all previously-unavailable sensor.*_mode / sensor.*_boost / sensor.*_state entities (10 TRVs + thermostat) and the hub's binary_sensor.hive_hub_status came back after this change, with no regressions to entities that were already working. Co-Authored-By: Claude Opus 5.5 --- src/devices/hub.py | 3 +- src/helper/compat_aliases.py | 148 +++++++++++++++++++++++++++++- tests/unit/test_compat_aliases.py | 14 +++ 3 files changed, 162 insertions(+), 3 deletions(-) diff --git a/src/devices/hub.py b/src/devices/hub.py index 52da410..f0ff2eb 100644 --- a/src/devices/hub.py +++ b/src/devices/hub.py @@ -4,6 +4,7 @@ from datetime import datetime from typing import Any +from ..helper.compat_aliases import HubCompatMixin from ..helper.const import HIVETOHA, HTTP_OK from ..helper.device_handler_base import BaseDeviceHandler from ..helper.hivedataclasses import Device @@ -11,7 +12,7 @@ _LOGGER = logging.getLogger(__name__) -class HiveHub(BaseDeviceHandler): +class HiveHub(HubCompatMixin, BaseDeviceHandler): """Hive hub. Returns: diff --git a/src/helper/compat_aliases.py b/src/helper/compat_aliases.py index 8cdfc0e..ac2c03b 100644 --- a/src/helper/compat_aliases.py +++ b/src/helper/compat_aliases.py @@ -32,10 +32,66 @@ async def setBoostOff(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for set_boost_off.""" return await self.set_boost_off(device) # type: ignore[attr-defined] + async def setHeatOnDemand(self, device: Device, state: str): # pylint: disable=invalid-name + """Backwards-compatible alias for set_heat_on_demand.""" + return await self.set_heat_on_demand(device, state) # type: ignore[attr-defined] + async def getClimate(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for get_climate.""" return await self.get_climate(device) # type: ignore[attr-defined] + async def getMinTemperature(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_min_temperature.""" + return await self.get_min_temperature(device) # type: ignore[attr-defined] + + async def getMaxTemperature(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_max_temperature.""" + return await self.get_max_temperature(device) # type: ignore[attr-defined] + + async def getCurrentTemperature(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_current_temperature.""" + return await self.get_current_temperature(device) # type: ignore[attr-defined] + + async def getTargetTemperature(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_target_temperature.""" + return await self.get_target_temperature(device) # type: ignore[attr-defined] + + async def getMode(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_mode.""" + return await self.get_mode(device) # type: ignore[attr-defined] + + async def getState(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_state.""" + return await self.get_state(device) # type: ignore[attr-defined] + + async def getCurrentOperation(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_current_operation.""" + return await self.get_current_operation(device) # type: ignore[attr-defined] + + async def getBoostStatus(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_boost_status.""" + return await self.get_boost_status(device) # type: ignore[attr-defined] + + async def getBoostTime(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_boost_time.""" + return await self.get_boost_time(device) # type: ignore[attr-defined] + + async def getHeatOnDemand(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_heat_on_demand.""" + return await self.get_heat_on_demand(device) # type: ignore[attr-defined] + + async def getOperationModes(self): # pylint: disable=invalid-name + """Backwards-compatible alias for get_operation_modes.""" + return await self.get_operation_modes() # type: ignore[attr-defined] + + async def getScheduleNowNextLater(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_schedule_now_next_later.""" + return await self.get_schedule_now_next_later(device) # type: ignore[attr-defined] + + async def minmaxTemperature(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for minmax_temperature.""" + return await self.minmax_temperature(device) # type: ignore[attr-defined] + class LightCompatMixin: """CamelCase aliases for Light public methods.""" @@ -56,6 +112,34 @@ async def getLight(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for get_light.""" return await self.get_light(device) # type: ignore[attr-defined] + async def getState(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_state.""" + return await self.get_state(device) # type: ignore[attr-defined] + + async def getBrightness(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_brightness.""" + return await self.get_brightness(device) # type: ignore[attr-defined] + + async def getMinColorTemp(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_min_color_temp.""" + return await self.get_min_color_temp(device) # type: ignore[attr-defined] + + async def getMaxColorTemp(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_max_color_temp.""" + return await self.get_max_color_temp(device) # type: ignore[attr-defined] + + async def getColorTemp(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_color_temp.""" + return await self.get_color_temp(device) # type: ignore[attr-defined] + + async def getColor(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_color.""" + return await self.get_color(device) # type: ignore[attr-defined] + + async def getColorMode(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_color_mode.""" + return await self.get_color_mode(device) # type: ignore[attr-defined] + class SwitchCompatMixin: """CamelCase aliases for Switch (plug) public methods.""" @@ -72,14 +156,34 @@ async def getSwitch(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for get_switch.""" return await self.get_switch(device) # type: ignore[attr-defined] + async def getState(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_state.""" + return await self.get_state(device) # type: ignore[attr-defined] + + async def getPowerUsage(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_power_usage.""" + return await self.get_power_usage(device) # type: ignore[attr-defined] + + async def getSwitchState(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_switch_state.""" + return await self.get_switch_state(device) # type: ignore[attr-defined] + class WaterHeaterCompatMixin: """CamelCase aliases for WaterHeater (hotwater) public methods.""" - async def get_boost(self, device: Device): # pylint: disable=invalid-name + async def get_boost(self, device: Device): + """Alias for get_boost_status, used by the Hotwater_Boost sensor.""" + return await self.get_boost_status(device) # type: ignore[attr-defined] + + async def getBoost(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for get_boost_status.""" return await self.get_boost_status(device) # type: ignore[attr-defined] + async def getBoostTime(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_boost_time.""" + return await self.get_boost_time(device) # 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] @@ -96,14 +200,34 @@ async def getWaterHeater(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for get_water_heater.""" return await self.get_water_heater(device) # type: ignore[attr-defined] + async def getMode(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_mode.""" + return await self.get_mode(device) # type: ignore[attr-defined] + + async def getState(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_state.""" + return await self.get_state(device) # type: ignore[attr-defined] -class SensorCompatMixin: # pylint: disable=too-few-public-methods + async def getOperationModes(self): # pylint: disable=invalid-name + """Backwards-compatible alias for get_operation_modes.""" + return await self.get_operation_modes() # type: ignore[attr-defined] + + async def getScheduleNowNextLater(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_schedule_now_next_later.""" + return await self.get_schedule_now_next_later(device) # type: ignore[attr-defined] + + +class SensorCompatMixin: """CamelCase aliases for Sensor public methods.""" async def getSensor(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for get_sensor.""" return await self.get_sensor(device) # type: ignore[attr-defined] + async def getState(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_state.""" + return await self.get_state(device) # type: ignore[attr-defined] + class ActionCompatMixin: """CamelCase aliases for HiveAction public methods.""" @@ -120,6 +244,26 @@ async def setStatusOff(self, device: Device): # pylint: disable=invalid-name """Backwards-compatible alias for set_status_off.""" return await self.set_status_off(device) # type: ignore[attr-defined] + async def getState(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_state.""" + return await self.get_state(device) # type: ignore[attr-defined] + + +class HubCompatMixin: + """CamelCase aliases for HiveHub public methods.""" + + async def getSmokeStatus(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_smoke_status.""" + return await self.get_smoke_status(device) # type: ignore[attr-defined] + + async def getDogBarkStatus(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_dog_bark_status.""" + return await self.get_dog_bark_status(device) # type: ignore[attr-defined] + + async def getGlassBreakStatus(self, device: Device): # pylint: disable=invalid-name + """Backwards-compatible alias for get_glass_break_status.""" + return await self.get_glass_break_status(device) # type: ignore[attr-defined] + class SessionCompatMixin: """CamelCase and legacy aliases for HiveSession public methods.""" diff --git a/tests/unit/test_compat_aliases.py b/tests/unit/test_compat_aliases.py index 8cc255f..a08cd39 100644 --- a/tests/unit/test_compat_aliases.py +++ b/tests/unit/test_compat_aliases.py @@ -220,6 +220,20 @@ class Stub(WaterHeaterCompatMixin): s.get_boost_status.assert_called_once_with(d) assert result == "OFF" + async def test_camelcase_get_boost_delegates_to_get_boost_status(self): + """getBoost delegates to get_boost_status and returns its result.""" + + class Stub(WaterHeaterCompatMixin): + """Stub with mocked get_boost_status.""" + + get_boost_status = AsyncMock(return_value="ON") + + s = Stub() + d = _make_device() + result = await s.getBoost(d) + s.get_boost_status.assert_called_once_with(d) + assert result == "ON" + async def test_set_mode_delegates(self): """setMode delegates to set_mode.""" From a16f33ad3ed7192203ef262c6813342c54d9af0e Mon Sep 17 00:00:00 2001 From: Neil Sleightholm Date: Tue, 29 Sep 2026 11:12:32 +0100 Subject: [PATCH 2/2] test: cover every new camelCase getter alias The alias commit only tested getBoost, leaving 34 new delegates uncovered and dropping total coverage to 98.95%, below the 99% fail_under gate. Add one parametrized test asserting each alias calls its snake_case method with the same arguments and returns its result. Co-Authored-By: Claude Opus 5.5 --- tests/unit/test_compat_aliases.py | 75 +++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/unit/test_compat_aliases.py b/tests/unit/test_compat_aliases.py index a08cd39..16c3440 100644 --- a/tests/unit/test_compat_aliases.py +++ b/tests/unit/test_compat_aliases.py @@ -4,9 +4,11 @@ from unittest.mock import AsyncMock +import pytest from apyhiveapi.helper.compat_aliases import ( ActionCompatMixin, HeatingCompatMixin, + HubCompatMixin, LightCompatMixin, SensorCompatMixin, SessionCompatMixin, @@ -471,3 +473,76 @@ class Stub(ActionCompatMixin): result = await s.setStatusOff(d) s.set_status_off.assert_called_once_with(d) assert result is True + + +# --------------------------------------------------------------------------- +# Getter aliases called by Home Assistant core's hive platforms +# --------------------------------------------------------------------------- + +_DEVICE = object() + +_GETTER_ALIASES = [ + (HeatingCompatMixin, "getMinTemperature", "get_min_temperature", (_DEVICE,)), + (HeatingCompatMixin, "getMaxTemperature", "get_max_temperature", (_DEVICE,)), + ( + HeatingCompatMixin, + "getCurrentTemperature", + "get_current_temperature", + (_DEVICE,), + ), + (HeatingCompatMixin, "getTargetTemperature", "get_target_temperature", (_DEVICE,)), + (HeatingCompatMixin, "getMode", "get_mode", (_DEVICE,)), + (HeatingCompatMixin, "getState", "get_state", (_DEVICE,)), + (HeatingCompatMixin, "getCurrentOperation", "get_current_operation", (_DEVICE,)), + (HeatingCompatMixin, "getBoostStatus", "get_boost_status", (_DEVICE,)), + (HeatingCompatMixin, "getBoostTime", "get_boost_time", (_DEVICE,)), + (HeatingCompatMixin, "getHeatOnDemand", "get_heat_on_demand", (_DEVICE,)), + (HeatingCompatMixin, "setHeatOnDemand", "set_heat_on_demand", (_DEVICE, "ENABLED")), + (HeatingCompatMixin, "getOperationModes", "get_operation_modes", ()), + ( + HeatingCompatMixin, + "getScheduleNowNextLater", + "get_schedule_now_next_later", + (_DEVICE,), + ), + (HeatingCompatMixin, "minmaxTemperature", "minmax_temperature", (_DEVICE,)), + (LightCompatMixin, "getState", "get_state", (_DEVICE,)), + (LightCompatMixin, "getBrightness", "get_brightness", (_DEVICE,)), + (LightCompatMixin, "getMinColorTemp", "get_min_color_temp", (_DEVICE,)), + (LightCompatMixin, "getMaxColorTemp", "get_max_color_temp", (_DEVICE,)), + (LightCompatMixin, "getColorTemp", "get_color_temp", (_DEVICE,)), + (LightCompatMixin, "getColor", "get_color", (_DEVICE,)), + (LightCompatMixin, "getColorMode", "get_color_mode", (_DEVICE,)), + (SwitchCompatMixin, "getState", "get_state", (_DEVICE,)), + (SwitchCompatMixin, "getPowerUsage", "get_power_usage", (_DEVICE,)), + (SwitchCompatMixin, "getSwitchState", "get_switch_state", (_DEVICE,)), + (WaterHeaterCompatMixin, "getBoostTime", "get_boost_time", (_DEVICE,)), + (WaterHeaterCompatMixin, "getMode", "get_mode", (_DEVICE,)), + (WaterHeaterCompatMixin, "getState", "get_state", (_DEVICE,)), + (WaterHeaterCompatMixin, "getOperationModes", "get_operation_modes", ()), + ( + WaterHeaterCompatMixin, + "getScheduleNowNextLater", + "get_schedule_now_next_later", + (_DEVICE,), + ), + (SensorCompatMixin, "getState", "get_state", (_DEVICE,)), + (ActionCompatMixin, "getState", "get_state", (_DEVICE,)), + (HubCompatMixin, "getSmokeStatus", "get_smoke_status", (_DEVICE,)), + (HubCompatMixin, "getDogBarkStatus", "get_dog_bark_status", (_DEVICE,)), + (HubCompatMixin, "getGlassBreakStatus", "get_glass_break_status", (_DEVICE,)), +] + + +@pytest.mark.parametrize( + ("mixin", "alias", "target", "args"), + _GETTER_ALIASES, + ids=[f"{m.__name__}.{a}" for m, a, _, _ in _GETTER_ALIASES], +) +async def test_getter_alias_delegates(mixin, alias, target, args): + """Each camelCase alias delegates to its snake_case method and returns its result.""" + sentinel = object() + stub = type("Stub", (mixin,), {target: AsyncMock(return_value=sentinel)})() + result = await getattr(stub, alias)(*args) + getattr(stub, target).assert_called_once_with(*args) + assert result is sentinel