From 4e94ce0cc7f76cb3c0eec2c68819b199878127fb Mon Sep 17 00:00:00 2001
From: Willem-Jan van Rootselaar <liudgervr@gmail.com>
Date: Fri, 23 Aug 2024 08:42:36 +0200
Subject: [PATCH] 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
---
 homeassistant/components/bsblan/__init__.py   |  4 +-
 homeassistant/components/bsblan/climate.py    | 61 +++++++------------
 .../components/bsblan/coordinator.py          | 49 ++++++++-------
 .../components/bsblan/diagnostics.py          |  7 ++-
 homeassistant/components/bsblan/entity.py     | 42 ++++++-------
 tests/components/bsblan/conftest.py           |  8 ++-
 tests/components/bsblan/fixtures/static.json  | 20 ++++++
 tests/components/bsblan/test_diagnostics.py   |  7 +--
 8 files changed, 102 insertions(+), 96 deletions(-)
 create mode 100644 tests/components/bsblan/fixtures/static.json

diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py
index 113a582f403..5ce90db5043 100644
--- a/homeassistant/components/bsblan/__init__.py
+++ b/homeassistant/components/bsblan/__init__.py
@@ -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,
diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py
index 4d6514251cb..ae7116143df 100644
--- a/homeassistant/components/bsblan/climate.py
+++ b/homeassistant/components/bsblan/climate.py
@@ -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"
-
-        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 "&deg;C" or "°C"
-        if static.min_temp.unit in ("&deg;C", "°C"):
+        super().__init__(data.coordinator, data)
+        self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
+
+        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 ("&deg;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",
diff --git a/homeassistant/components/bsblan/coordinator.py b/homeassistant/components/bsblan/coordinator.py
index 864daacc562..3320c0f7500 100644
--- a/homeassistant/components/bsblan/coordinator.py
+++ b/homeassistant/components/bsblan/coordinator.py
@@ -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)
diff --git a/homeassistant/components/bsblan/diagnostics.py b/homeassistant/components/bsblan/diagnostics.py
index a24082fd698..3b42d47e1d3 100644
--- a/homeassistant/components/bsblan/diagnostics.py
+++ b/homeassistant/components/bsblan/diagnostics.py
@@ -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(),
     }
diff --git a/homeassistant/components/bsblan/entity.py b/homeassistant/components/bsblan/entity.py
index a69c4d2217e..0c507938794 100644
--- a/homeassistant/components/bsblan/entity.py
+++ b/homeassistant/components/bsblan/entity.py
@@ -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}",
         )
diff --git a/tests/components/bsblan/conftest.py b/tests/components/bsblan/conftest.py
index 07ca8b648f3..13d4017d7c8 100644
--- a/tests/components/bsblan/conftest.py
+++ b/tests/components/bsblan/conftest.py
@@ -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
 
 
diff --git a/tests/components/bsblan/fixtures/static.json b/tests/components/bsblan/fixtures/static.json
new file mode 100644
index 00000000000..8c7abc3397b
--- /dev/null
+++ b/tests/components/bsblan/fixtures/static.json
@@ -0,0 +1,20 @@
+{
+  "min_temp": {
+    "name": "Room temp frost protection setpoint",
+    "error": 0,
+    "value": "8.0",
+    "desc": "",
+    "dataType": 0,
+    "readonly": 0,
+    "unit": "&deg;C"
+  },
+  "max_temp": {
+    "name": "Summer/winter changeover temp heat circuit 1",
+    "error": 0,
+    "value": "20.0",
+    "desc": "",
+    "dataType": 0,
+    "readonly": 0,
+    "unit": "&deg;C"
+  }
+}
diff --git a/tests/components/bsblan/test_diagnostics.py b/tests/components/bsblan/test_diagnostics.py
index 316296df78a..8939456c2ac 100644
--- a/tests/components/bsblan/test_diagnostics.py
+++ b/tests/components/bsblan/test_diagnostics.py
@@ -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
-- 
GitLab