Refactor bsblan coordinator (#124308)

* chore: Refactor BSBLanUpdateCoordinator to improve code readability and maintainability

* feat: Add BSBLan integration models

This commit adds the models for the BSB-Lan integration. It includes a dataclass for the BSBLanCoordinatorData, which stores the state and sensor information.

* refactor: Update BSBLANClimate class to use DataUpdateCoordinator without specifying the State type

* chore: Remove unused Sensor import in BSBLan models

* feat: Refactor BSBLanEntity to use CoordinatorEntity

The BSBLanEntity class has been refactored to inherit from the CoordinatorEntity class, which provides better integration with the update coordinator. This change improves code readability and maintainability.

* refactor: Remove unused config_entry variable in BSBLanUpdateCoordinator

* refactor: Update BSBLANClimate class to use DataUpdateCoordinator

Refactor the BSBLANClimate class to use the Coordinator of the entity

* refactor: Update tests to use the new structure

* fix coverage

 it should be the same as before

* refactor: moved dataclass BSBLanCoordinatorData

* use the data class inside init

* refactor: Remove unused config_entry variable in BSBLanUpdateCoordinator

* refactor: use BSBLanData from init

* remove entry data from diagnostics

* fix: add random interval back

* refactor: Simplify coordinator_data assignment in async_get_config_entry_diagnostics

* revert back to original except dataclass import

* revert: Add MAC address back to device info in BSBLanEntity
This commit is contained in:
Willem-Jan van Rootselaar 2024-08-23 08:42:36 +02:00 committed by GitHub
parent 7c6e3fe9c4
commit 4e94ce0cc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 101 additions and 95 deletions

View file

@ -22,7 +22,7 @@ PLATFORMS = [Platform.CLIMATE]
@dataclasses.dataclass
class HomeAssistantBSBLANData:
class BSBLanData:
"""BSBLan data stored in the Home Assistant data object."""
coordinator: BSBLanUpdateCoordinator
@ -57,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
info = await bsblan.info()
static = await bsblan.static_values()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantBSBLANData(
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = BSBLanData(
client=bsblan,
coordinator=coordinator,
device=device,

View file

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any
from bsblan import BSBLAN, BSBLANError, Device, Info, State, StaticState
from bsblan import BSBLANError
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
@ -21,15 +21,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from homeassistant.util.enum import try_parse_enum
from . import HomeAssistantBSBLANData
from . import BSBLanData
from .const import ATTR_TARGET_TEMPERATURE, DOMAIN
from .entity import BSBLANEntity
from .entity import BSBLanEntity
PARALLEL_UPDATES = 1
@ -51,24 +47,17 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up BSBLAN device based on a config entry."""
data: HomeAssistantBSBLANData = hass.data[DOMAIN][entry.entry_id]
data: BSBLanData = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
[
BSBLANClimate(
data.coordinator,
data.client,
data.device,
data.info,
data.static,
entry,
data,
)
]
)
class BSBLANClimate(
BSBLANEntity, CoordinatorEntity[DataUpdateCoordinator[State]], ClimateEntity
):
class BSBLANClimate(BSBLanEntity, ClimateEntity):
"""Defines a BSBLAN climate device."""
_attr_has_entity_name = True
@ -80,30 +69,22 @@ class BSBLANClimate(
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
_attr_preset_modes = PRESET_MODES
# Determine hvac modes
_attr_preset_modes = PRESET_MODES
_attr_hvac_modes = HVAC_MODES
_enable_turn_on_off_backwards_compatibility = False
def __init__(
self,
coordinator: DataUpdateCoordinator[State],
client: BSBLAN,
device: Device,
info: Info,
static: StaticState,
entry: ConfigEntry,
data: BSBLanData,
) -> None:
"""Initialize BSBLAN climate device."""
super().__init__(client, device, info, static, entry)
CoordinatorEntity.__init__(self, coordinator)
self._attr_unique_id = f"{format_mac(device.MAC)}-climate"
super().__init__(data.coordinator, data)
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
self._attr_min_temp = float(static.min_temp.value)
self._attr_max_temp = float(static.max_temp.value)
# check if self.coordinator.data.current_temperature.unit is "°C" or "°C"
if static.min_temp.unit in ("°C", "°C"):
self._attr_min_temp = float(data.static.min_temp.value)
self._attr_max_temp = float(data.static.max_temp.value)
if data.static.min_temp.unit in ("°C", "°C"):
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
else:
self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT
@ -111,30 +92,30 @@ class BSBLANClimate(
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self.coordinator.data.current_temperature.value == "---":
if self.coordinator.data.state.current_temperature.value == "---":
# device returns no current temperature
return None
return float(self.coordinator.data.current_temperature.value)
return float(self.coordinator.data.state.current_temperature.value)
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
return float(self.coordinator.data.target_temperature.value)
return float(self.coordinator.data.state.target_temperature.value)
@property
def hvac_mode(self) -> HVACMode | None:
"""Return hvac operation ie. heat, cool mode."""
if self.coordinator.data.hvac_mode.value == PRESET_ECO:
if self.coordinator.data.state.hvac_mode.value == PRESET_ECO:
return HVACMode.AUTO
return try_parse_enum(HVACMode, self.coordinator.data.hvac_mode.value)
return try_parse_enum(HVACMode, self.coordinator.data.state.hvac_mode.value)
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
if (
self.hvac_mode == HVACMode.AUTO
and self.coordinator.data.hvac_mode.value == PRESET_ECO
and self.coordinator.data.state.hvac_mode.value == PRESET_ECO
):
return PRESET_ECO
return PRESET_NONE
@ -173,7 +154,7 @@ class BSBLANClimate(
else:
data[ATTR_HVAC_MODE] = kwargs[ATTR_PRESET_MODE]
try:
await self.client.thermostat(**data)
await self.coordinator.client.thermostat(**data)
except BSBLANError as err:
raise HomeAssistantError(
"An error occurred while updating the BSBLAN device",

View file

@ -1,12 +1,10 @@
"""DataUpdateCoordinator for the BSB-Lan integration."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from random import randint
from bsblan import BSBLAN, BSBLANConnectionError
from bsblan.models import State
from bsblan import BSBLAN, BSBLANConnectionError, State
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
@ -16,7 +14,14 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
class BSBLanUpdateCoordinator(DataUpdateCoordinator[State]):
@dataclass
class BSBLanCoordinatorData:
"""BSBLan data stored in the Home Assistant data object."""
state: State
class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]):
"""The BSB-Lan update coordinator."""
config_entry: ConfigEntry
@ -28,30 +33,32 @@ class BSBLanUpdateCoordinator(DataUpdateCoordinator[State]):
client: BSBLAN,
) -> None:
"""Initialize the BSB-Lan coordinator."""
self.client = client
super().__init__(
hass,
LOGGER,
logger=LOGGER,
name=f"{DOMAIN}_{config_entry.data[CONF_HOST]}",
# use the default scan interval and add a random number of seconds to avoid timeouts when
# the BSB-Lan device is already/still busy retrieving data,
# e.g. for MQTT or internal logging.
update_interval=SCAN_INTERVAL + timedelta(seconds=randint(1, 8)),
update_interval=self._get_update_interval(),
)
self.client = client
async def _async_update_data(self) -> State:
"""Get state from BSB-Lan device."""
def _get_update_interval(self) -> timedelta:
"""Get the update interval with a random offset.
# use the default scan interval and add a random number of seconds to avoid timeouts when
# the BSB-Lan device is already/still busy retrieving data, e.g. for MQTT or internal logging.
self.update_interval = SCAN_INTERVAL + timedelta(seconds=randint(1, 8))
Use the default scan interval and add a random number of seconds to avoid timeouts when
the BSB-Lan device is already/still busy retrieving data,
e.g. for MQTT or internal logging.
"""
return SCAN_INTERVAL + timedelta(seconds=randint(1, 8))
async def _async_update_data(self) -> BSBLanCoordinatorData:
"""Get state and sensor data from BSB-Lan device."""
try:
return await self.client.state()
state = await self.client.state()
except BSBLANConnectionError as err:
host = self.config_entry.data[CONF_HOST] if self.config_entry else "unknown"
raise UpdateFailed(
f"Error while establishing connection with "
f"BSB-Lan device at {self.config_entry.data[CONF_HOST]}"
f"Error while establishing connection with BSB-Lan device at {host}"
) from err
self.update_interval = self._get_update_interval()
return BSBLanCoordinatorData(state=state)

View file

@ -7,7 +7,7 @@ from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from . import HomeAssistantBSBLANData
from . import BSBLanData
from .const import DOMAIN
@ -15,9 +15,10 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
data: HomeAssistantBSBLANData = hass.data[DOMAIN][entry.entry_id]
data: BSBLanData = hass.data[DOMAIN][entry.entry_id]
return {
"info": data.info.to_dict(),
"device": data.device.to_dict(),
"state": data.coordinator.data.to_dict(),
"state": data.coordinator.data.state.to_dict(),
}

View file

@ -1,41 +1,35 @@
"""Base entity for the BSBLAN integration."""
"""BSBLan base entity."""
from __future__ import annotations
from bsblan import BSBLAN, Device, Info, StaticState
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import BSBLanData
from .const import DOMAIN
from .coordinator import BSBLanUpdateCoordinator
class BSBLANEntity(Entity):
"""Defines a BSBLAN entity."""
class BSBLanEntity(CoordinatorEntity[BSBLanUpdateCoordinator]):
"""Defines a base BSBLan entity."""
def __init__(
self,
client: BSBLAN,
device: Device,
info: Info,
static: StaticState,
entry: ConfigEntry,
) -> None:
"""Initialize an BSBLAN entity."""
self.client = client
_attr_has_entity_name = True
def __init__(self, coordinator: BSBLanUpdateCoordinator, data: BSBLanData) -> None:
"""Initialize BSBLan entity."""
super().__init__(coordinator, data)
host = self.coordinator.config_entry.data["host"]
mac = self.coordinator.config_entry.data["mac"]
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, format_mac(device.MAC))},
identifiers={(DOMAIN, format_mac(device.MAC))},
identifiers={(DOMAIN, data.device.MAC)},
connections={(CONNECTION_NETWORK_MAC, format_mac(mac))},
name=data.device.name,
manufacturer="BSBLAN Inc.",
model=info.device_identification.value,
name=device.name,
sw_version=f"{device.version})",
configuration_url=f"http://{entry.data[CONF_HOST]}",
model=data.info.device_identification.value,
sw_version=data.device.version,
configuration_url=f"http://{host}",
)

View file

@ -3,7 +3,7 @@
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from bsblan import Device, Info, State
from bsblan import Device, Info, State, StaticState
import pytest
from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN
@ -42,7 +42,6 @@ def mock_setup_entry() -> Generator[AsyncMock]:
@pytest.fixture
def mock_bsblan() -> Generator[MagicMock]:
"""Return a mocked BSBLAN client."""
with (
patch("homeassistant.components.bsblan.BSBLAN", autospec=True) as bsblan_mock,
patch("homeassistant.components.bsblan.config_flow.BSBLAN", new=bsblan_mock),
@ -53,6 +52,11 @@ def mock_bsblan() -> Generator[MagicMock]:
load_fixture("device.json", DOMAIN)
)
bsblan.state.return_value = State.from_json(load_fixture("state.json", DOMAIN))
bsblan.static_values.return_value = StaticState.from_json(
load_fixture("static.json", DOMAIN)
)
yield bsblan

View file

@ -0,0 +1,20 @@
{
"min_temp": {
"name": "Room temp frost protection setpoint",
"error": 0,
"value": "8.0",
"desc": "",
"dataType": 0,
"readonly": 0,
"unit": "°C"
},
"max_temp": {
"name": "Summer/winter changeover temp heat circuit 1",
"error": 0,
"value": "20.0",
"desc": "",
"dataType": 0,
"readonly": 0,
"unit": "°C"
}
}

View file

@ -16,8 +16,7 @@ async def test_diagnostics(
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
assert (
await get_diagnostics_for_config_entry(hass, hass_client, init_integration)
== snapshot
diagnostics_data = await get_diagnostics_for_config_entry(
hass, hass_client, init_integration
)
assert diagnostics_data == snapshot