Improve code quality of hr_energy_qube (#178280)

This commit is contained in:
Mattie
2026-08-31 21:46:40 +02:00
committed by GitHub
parent 7950dd4334
commit bfb55cc285
15 changed files with 107 additions and 296 deletions
@@ -1,57 +1,23 @@
"""The Qube Heat Pump integration."""
from dataclasses import dataclass
from python_qube_heatpump import QubeClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import PLATFORMS
from .coordinator import QubeCoordinator
@dataclass
class QubeData:
"""Runtime data for Qube Heat Pump."""
coordinator: QubeCoordinator
client: QubeClient
sw_version: str | None
type QubeConfigEntry = ConfigEntry[QubeData]
type QubeConfigEntry = ConfigEntry[QubeCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: QubeConfigEntry) -> bool:
"""Set up Qube Heat Pump from a config entry."""
client = QubeClient(entry.data[CONF_HOST], entry.data[CONF_PORT])
# Connect and read software version for device info
sw_version: str | None = None
try:
connected = await client.connect()
if not connected:
await client.close()
raise ConfigEntryNotReady(
f"Unable to connect to Qube heat pump at {entry.data[CONF_HOST]}"
)
sw_version = await client.async_get_software_version()
except (OSError, TimeoutError) as err:
await client.close()
raise ConfigEntryNotReady(
f"Unable to connect to Qube heat pump at {entry.data[CONF_HOST]}"
) from err
coordinator = QubeCoordinator(hass, client, entry)
entry.runtime_data = QubeData(
coordinator=coordinator,
client=client,
sw_version=sw_version,
)
entry.runtime_data = coordinator
await coordinator.async_config_entry_first_refresh()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -260,7 +260,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Qube binary sensors."""
coordinator = entry.runtime_data.coordinator
coordinator = entry.runtime_data
async_add_entities(
QubeBinarySensor(coordinator, entry, description)
@@ -38,7 +38,7 @@ class QubeConfigFlow(ConfigFlow, domain=DOMAIN):
version = await client.async_get_software_version()
if version is None:
errors["base"] = "not_qube_device"
except OSError, TimeoutError:
except OSError:
errors["base"] = "cannot_connect"
finally:
await client.close()
@@ -31,6 +31,8 @@ class QubeData:
class QubeCoordinator(DataUpdateCoordinator[QubeData]):
"""Qube Heat Pump data coordinator."""
sw_version: str | None = None
def __init__(
self, hass: HomeAssistant, client: QubeClient, entry: ConfigEntry
) -> None:
@@ -44,6 +46,23 @@ class QubeCoordinator(DataUpdateCoordinator[QubeData]):
config_entry=entry,
)
@override
async def _async_setup(self) -> None:
"""Connect to the device and read its software version."""
try:
connected = await self.client.connect()
if not connected:
await self.client.close()
raise UpdateFailed(
f"Unable to connect to Qube heat pump at {self.client.host}"
)
self.sw_version = await self.client.async_get_software_version()
except OSError as err:
await self.client.close()
raise UpdateFailed(
f"Unable to connect to Qube heat pump at {self.client.host}"
) from err
@override
async def _async_update_data(self) -> QubeData:
"""Fetch data from the device."""
@@ -51,7 +70,7 @@ class QubeCoordinator(DataUpdateCoordinator[QubeData]):
state = await self.client.get_all_data()
switches = await self.client.read_all_switches()
sg_ready_mode = await self.client.get_sg_ready_mode()
except (ConnectionError, TimeoutError, OSError) as exc:
except OSError as exc:
raise UpdateFailed(
f"Error communicating with Qube heat pump: {exc}"
) from exc
@@ -28,5 +28,5 @@ class QubeEntity(CoordinatorEntity[QubeCoordinator]):
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="Qube",
model="Heat Pump",
sw_version=entry.runtime_data.sw_version,
sw_version=coordinator.sw_version,
)
@@ -42,7 +42,7 @@ rules:
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
@@ -64,9 +64,9 @@ rules:
dynamic-devices:
status: exempt
comment: Single device per config entry.
entity-category: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: todo
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations: todo
@@ -23,7 +23,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Qube select entities."""
coordinator = entry.runtime_data.coordinator
coordinator = entry.runtime_data
async_add_entities([QubeSGReadySelect(coordinator, entry)])
@@ -59,7 +59,7 @@ class QubeSGReadySelect(QubeEntity, SelectEntity):
"""Set the SG Ready mode."""
try:
success = await self.coordinator.client.set_sg_ready_mode(option)
except (ConnectionError, TimeoutError, OSError) as err:
except OSError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_command_failed",
@@ -4,6 +4,8 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, override
from python_qube_heatpump import STATUS_CODE_MAP
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
@@ -31,21 +33,18 @@ if TYPE_CHECKING:
from . import QubeConfigEntry
from .coordinator import QubeCoordinator
# Status code to state mapping
# Status code to state mapping, derived from the library's status code map.
STATUS_MAP: dict[int, str] = {
1: "standby",
2: "alarm",
6: "keyboard_off",
8: "compressor_startup",
9: "compressor_shutdown",
14: "standby",
15: "cooling",
16: "heating",
17: "start_fail",
18: "standby",
22: "heating_dhw",
code: status.value for code, status in STATUS_CODE_MAP.items()
}
# Options list for the status sensor: unique status strings from STATUS_MAP,
# in first-seen order. StatusCode also defines ANTI_LEGIONELLA and UNKNOWN,
# but those are not part of STATUS_CODE_MAP (only surfaced via the library's
# resolve_status() helper, which this integration does not use), so they are
# excluded here automatically rather than needing an explicit filter.
STATUS_OPTIONS: list[str] = list(dict.fromkeys(STATUS_MAP.values()))
@dataclass(frozen=True, kw_only=True)
class QubeSensorEntityDescription(SensorEntityDescription):
@@ -226,17 +225,7 @@ SENSOR_TYPES: tuple[QubeSensorEntityDescription, ...] = (
key="status_heatpump",
translation_key="status_heatpump",
device_class=SensorDeviceClass.ENUM,
options=[
"standby",
"alarm",
"keyboard_off",
"compressor_startup",
"compressor_shutdown",
"cooling",
"heating",
"start_fail",
"heating_dhw",
],
options=STATUS_OPTIONS,
value_fn=_status_value,
),
)
@@ -248,7 +237,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Qube sensors."""
coordinator = entry.runtime_data.coordinator
coordinator = entry.runtime_data
async_add_entities(
QubeSensor(coordinator, entry, description) for description in SENSOR_TYPES
@@ -55,7 +55,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Qube switches."""
coordinator = entry.runtime_data.coordinator
coordinator = entry.runtime_data
async_add_entities(
QubeSwitch(coordinator, entry, description) for description in SWITCH_TYPES
@@ -98,7 +98,7 @@ class QubeSwitch(QubeEntity, SwitchEntity):
register_key = self.entity_description.register_key
try:
success = await self.coordinator.client.write_switch(register_key, value)
except (ConnectionError, TimeoutError, OSError) as err:
except OSError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_command_failed",
@@ -3,6 +3,7 @@
from typing import Any, override
from homeassistant.components.water_heater import (
ATTR_TEMPERATURE,
STATE_HEAT_PUMP,
STATE_PERFORMANCE,
WaterHeaterEntity,
@@ -34,7 +35,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Qube water heater."""
coordinator = entry.runtime_data.coordinator
coordinator = entry.runtime_data
async_add_entities([QubeWaterHeater(coordinator, entry)])
@@ -86,14 +87,12 @@ class QubeWaterHeater(QubeEntity, WaterHeaterEntity):
@override
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the target DHW temperature."""
temperature = kwargs.get("temperature")
if temperature is None:
return
temperature = kwargs[ATTR_TEMPERATURE]
try:
success = await self.coordinator.client.write_setpoint(
DHW_SETPOINT_KEY, temperature
)
except (ConnectionError, TimeoutError, OSError) as err:
except OSError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_temperature_failed",
@@ -111,7 +110,7 @@ class QubeWaterHeater(QubeEntity, WaterHeaterEntity):
boost = operation_mode == STATE_PERFORMANCE
try:
success = await self.coordinator.client.write_switch(DHW_BOOST_KEY, boost)
except (ConnectionError, TimeoutError, OSError) as err:
except OSError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="switch_command_failed",
@@ -1,19 +1,17 @@
"""Tests for the Qube Heat Pump binary sensor platform."""
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@@ -32,41 +30,3 @@ async def test_entities(
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("side_effect", "return_value"),
[
(ConnectionError("Connection lost"), None),
(None, None),
],
)
async def test_binary_sensor_unavailable_on_coordinator_error(
hass: HomeAssistant,
mock_qube_client: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
side_effect: Exception | None,
return_value: None,
) -> None:
"""Test binary sensors become unavailable when coordinator fails."""
await setup_integration(hass, mock_config_entry)
# Verify binary sensors are available after setup
states = hass.states.async_all("binary_sensor")
assert len(states) > 0
assert all(s.state != STATE_UNAVAILABLE for s in states)
# Make the next fetch fail
mock_qube_client.get_all_data = AsyncMock(
side_effect=side_effect, return_value=return_value
)
# Skip time to trigger coordinator refresh
freezer.tick(timedelta(seconds=31))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# All binary sensors should be unavailable
states = hass.states.async_all("binary_sensor")
assert all(s.state == STATE_UNAVAILABLE for s in states)
@@ -2,6 +2,8 @@
from unittest.mock import MagicMock
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
@@ -25,3 +27,29 @@ async def test_setup_and_unload_entry(
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
mock_qube_client.close.assert_called_once()
@pytest.mark.parametrize(
("connect_result", "connect_error"),
[
(False, None),
(None, OSError("Connection refused")),
],
)
async def test_setup_entry_connection_failure(
hass: HomeAssistant,
mock_qube_client: MagicMock,
mock_config_entry: MockConfigEntry,
connect_result: bool | None,
connect_error: Exception | None,
) -> None:
"""Test setup failure when the device cannot be reached."""
if connect_error is not None:
mock_qube_client.connect.side_effect = connect_error
else:
mock_qube_client.connect.return_value = connect_result
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
mock_qube_client.close.assert_called_once()
+20 -77
View File
@@ -1,9 +1,7 @@
"""Tests for the Qube Heat Pump select platform."""
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -12,29 +10,16 @@ from homeassistant.components.select import (
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
from tests.common import MockConfigEntry, snapshot_platform
async def _setup_select(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> str:
"""Set up the select platform and return the entity_id."""
with patch("homeassistant.components.hr_energy_qube.PLATFORMS", [Platform.SELECT]):
await setup_integration(hass, mock_config_entry)
entity_registry = er.async_get(hass)
entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
assert len(entries) == 1
return entries[0].entity_id
ENTITY_ID = "select.qube_heat_pump_smart_grid_ready_mode"
async def test_entities(
@@ -59,84 +44,42 @@ async def test_select_option(
option: str,
) -> None:
"""Test selecting an SG Ready mode."""
entity_id = await _setup_select(hass, mock_config_entry)
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: entity_id, ATTR_OPTION: option},
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_OPTION: option},
blocking=True,
)
mock_qube_client.set_sg_ready_mode.assert_awaited_once_with(option)
async def test_select_option_connection_error(
hass: HomeAssistant,
mock_qube_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test select raises HomeAssistantError on connection error."""
entity_id = await _setup_select(hass, mock_config_entry)
mock_qube_client.set_sg_ready_mode = AsyncMock(side_effect=ConnectionError)
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "plus"},
blocking=True,
)
async def test_select_option_write_failure(
hass: HomeAssistant,
mock_qube_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test select raises HomeAssistantError when write fails."""
entity_id = await _setup_select(hass, mock_config_entry)
mock_qube_client.set_sg_ready_mode = AsyncMock(return_value=False)
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "plus"},
blocking=True,
)
@pytest.mark.parametrize(
("side_effect", "return_value"),
[
(ConnectionError("Connection lost"), None),
(None, None),
(ConnectionError, None),
(None, False),
],
)
async def test_select_unavailable_on_coordinator_error(
async def test_select_option_error(
hass: HomeAssistant,
mock_qube_client: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
side_effect: Exception | None,
return_value: None,
side_effect: type[Exception] | None,
return_value: bool | None,
) -> None:
"""Test select becomes unavailable when coordinator fails."""
entity_id = await _setup_select(hass, mock_config_entry)
"""Test select raises HomeAssistantError on connection error or write failure."""
await setup_integration(hass, mock_config_entry)
state = hass.states.get(entity_id)
assert state is not None
assert state.state != STATE_UNAVAILABLE
mock_qube_client.get_all_data = AsyncMock(
mock_qube_client.set_sg_ready_mode = AsyncMock(
side_effect=side_effect, return_value=return_value
)
freezer.tick(timedelta(seconds=31))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_OPTION: "plus"},
blocking=True,
)
+4 -60
View File
@@ -1,19 +1,15 @@
"""Tests for the Qube Heat Pump switch platform."""
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.hr_energy_qube.const import DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_UNAVAILABLE,
Platform,
)
from homeassistant.core import HomeAssistant
@@ -22,19 +18,9 @@ from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
from tests.common import MockConfigEntry, snapshot_platform
HEATING_DEMAND_KEY = "heating_demand"
def _get_entity_id(
entity_registry: er.EntityRegistry, entry: MockConfigEntry, key: str
) -> str:
"""Look up entity_id by key and config entry."""
unique_id = f"{entry.entry_id}-{key}"
entity_id = entity_registry.async_get_entity_id(SWITCH_DOMAIN, DOMAIN, unique_id)
assert entity_id is not None
return entity_id
ENTITY_ID = "switch.qube_heat_pump_heating_demand"
async def test_entities(
@@ -63,7 +49,6 @@ async def test_entities(
)
async def test_turn_on_off(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_qube_client: MagicMock,
mock_config_entry: MockConfigEntry,
service: str,
@@ -72,11 +57,10 @@ async def test_turn_on_off(
"""Test turning a switch on and off."""
await setup_integration(hass, mock_config_entry)
entity_id = _get_entity_id(entity_registry, mock_config_entry, HEATING_DEMAND_KEY)
await hass.services.async_call(
SWITCH_DOMAIN,
service,
{ATTR_ENTITY_ID: entity_id},
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
@@ -94,7 +78,6 @@ async def test_turn_on_off(
)
async def test_turn_on_error(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_qube_client: MagicMock,
mock_config_entry: MockConfigEntry,
side_effect: type[Exception] | None,
@@ -106,49 +89,10 @@ async def test_turn_on_error(
mock_qube_client.write_switch = AsyncMock(
side_effect=side_effect, return_value=return_value
)
entity_id = _get_entity_id(entity_registry, mock_config_entry, HEATING_DEMAND_KEY)
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id},
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
@pytest.mark.parametrize(
("side_effect", "return_value"),
[
(ConnectionError("Connection lost"), None),
(None, None),
],
)
async def test_switch_unavailable_on_coordinator_error(
hass: HomeAssistant,
mock_qube_client: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
side_effect: Exception | None,
return_value: None,
) -> None:
"""Test switches become unavailable when coordinator fails."""
await setup_integration(hass, mock_config_entry)
# Verify switches are available after setup
states = hass.states.async_all("switch")
assert len(states) > 0
assert all(s.state != STATE_UNAVAILABLE for s in states)
# Make the next fetch fail
mock_qube_client.get_all_data = AsyncMock(
side_effect=side_effect, return_value=return_value
)
# Skip time to trigger coordinator refresh
freezer.tick(timedelta(seconds=31))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# All switches should be unavailable
states = hass.states.async_all("switch")
assert all(s.state == STATE_UNAVAILABLE for s in states)
@@ -1,9 +1,7 @@
"""Tests for the Qube Heat Pump water heater platform."""
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -16,14 +14,14 @@ from homeassistant.components.water_heater import (
STATE_HEAT_PUMP,
STATE_PERFORMANCE,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
from tests.common import MockConfigEntry, snapshot_platform
ENTITY_ID = "water_heater.qube_heat_pump_domestic_hot_water"
@@ -124,38 +122,3 @@ async def test_current_operation_boost(
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.attributes["operation_mode"] == STATE_PERFORMANCE
@pytest.mark.parametrize(
("side_effect", "return_value"),
[
(ConnectionError("Connection lost"), None),
(None, None),
],
)
async def test_water_heater_unavailable_on_coordinator_error(
hass: HomeAssistant,
mock_qube_client: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
side_effect: Exception | None,
return_value: None,
) -> None:
"""Test water heater becomes unavailable when coordinator fails."""
await setup_integration(hass, mock_config_entry)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state != STATE_UNAVAILABLE
mock_qube_client.get_all_data = AsyncMock(
side_effect=side_effect, return_value=return_value
)
freezer.tick(timedelta(seconds=31))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE