Skip to content

Commit 58182a3

Browse files
liudgerjoostlek
andauthored
Reduce API calls in BSBlan (#152704)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
1 parent 1a1f3d6 commit 58182a3

File tree

19 files changed

+878
-82
lines changed

19 files changed

+878
-82
lines changed

homeassistant/components/bsblan/__init__.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from homeassistant.helpers.aiohttp_client import async_get_clientsession
3131

3232
from .const import CONF_PASSKEY, DOMAIN
33-
from .coordinator import BSBLanUpdateCoordinator
33+
from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator
3434

3535
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
3636

@@ -41,7 +41,8 @@
4141
class BSBLanData:
4242
"""BSBLan data stored in the Home Assistant data object."""
4343

44-
coordinator: BSBLanUpdateCoordinator
44+
fast_coordinator: BSBLanFastCoordinator
45+
slow_coordinator: BSBLanSlowCoordinator
4546
client: BSBLAN
4647
device: Device
4748
info: Info
@@ -64,12 +65,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
6465
session = async_get_clientsession(hass)
6566
bsblan = BSBLAN(config, session)
6667

67-
# Create and perform first refresh of the coordinator
68-
coordinator = BSBLanUpdateCoordinator(hass, entry, bsblan)
69-
await coordinator.async_config_entry_first_refresh()
70-
7168
try:
72-
# Fetch all required data sequentially
69+
# Initialize the client first - this sets up internal caches and validates the connection
70+
await bsblan.initialize()
71+
# Fetch all required device metadata
7372
device = await bsblan.device()
7473
info = await bsblan.info()
7574
static = await bsblan.static_values()
@@ -84,15 +83,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo
8483
translation_domain=DOMAIN,
8584
translation_key="setup_auth_error",
8685
) from err
86+
except TimeoutError as err:
87+
raise ConfigEntryNotReady(
88+
translation_domain=DOMAIN,
89+
translation_key="setup_connection_error",
90+
translation_placeholders={"host": entry.data[CONF_HOST]},
91+
) from err
8792
except BSBLANError as err:
8893
raise ConfigEntryError(
8994
translation_domain=DOMAIN,
9095
translation_key="setup_general_error",
9196
) from err
9297

98+
# Create coordinators with the already-initialized client
99+
fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan)
100+
slow_coordinator = BSBLanSlowCoordinator(hass, entry, bsblan)
101+
102+
# Perform first refresh of both coordinators
103+
await fast_coordinator.async_config_entry_first_refresh()
104+
105+
# Try to refresh slow coordinator, but don't fail if DHW is not available
106+
# This allows the integration to work even if the device doesn't support DHW
107+
await slow_coordinator.async_refresh()
108+
93109
entry.runtime_data = BSBLanData(
94110
client=bsblan,
95-
coordinator=coordinator,
111+
fast_coordinator=fast_coordinator,
112+
slow_coordinator=slow_coordinator,
96113
device=device,
97114
info=info,
98115
static=static,

homeassistant/components/bsblan/climate.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,12 @@ def __init__(
7171
data: BSBLanData,
7272
) -> None:
7373
"""Initialize BSBLAN climate device."""
74-
super().__init__(data.coordinator, data)
74+
super().__init__(data.fast_coordinator, data)
7575
self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate"
7676

7777
self._attr_min_temp = data.static.min_temp.value
7878
self._attr_max_temp = data.static.max_temp.value
79-
self._attr_temperature_unit = data.coordinator.client.get_temperature_unit
79+
self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit
8080

8181
@property
8282
def current_temperature(self) -> float | None:

homeassistant/components/bsblan/config_flow.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,10 +180,7 @@ async def async_step_reauth_confirm(
180180
self, user_input: dict[str, Any] | None = None
181181
) -> ConfigFlowResult:
182182
"""Handle reauth confirmation flow."""
183-
existing_entry = self.hass.config_entries.async_get_entry(
184-
self.context["entry_id"]
185-
)
186-
assert existing_entry
183+
existing_entry = self._get_reauth_entry()
187184

188185
if user_input is None:
189186
# Preserve existing values as defaults

homeassistant/components/bsblan/const.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
DOMAIN: Final = "bsblan"
1111

1212
LOGGER = logging.getLogger(__package__)
13-
SCAN_INTERVAL = timedelta(seconds=12)
13+
SCAN_INTERVAL = timedelta(seconds=12) # Legacy interval, kept for compatibility
14+
SCAN_INTERVAL_FAST = timedelta(seconds=12) # For state/sensor data
15+
SCAN_INTERVAL_SLOW = timedelta(minutes=5) # For config data
1416

1517
# Services
1618
DATA_BSBLAN_CLIENT: Final = "bsblan_client"

homeassistant/components/bsblan/coordinator.py

Lines changed: 104 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
BSBLAN,
99
BSBLANAuthError,
1010
BSBLANConnectionError,
11+
HotWaterConfig,
12+
HotWaterSchedule,
1113
HotWaterState,
1214
Sensor,
1315
State,
@@ -19,20 +21,28 @@
1921
from homeassistant.exceptions import ConfigEntryAuthFailed
2022
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
2123

22-
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
24+
from .const import DOMAIN, LOGGER, SCAN_INTERVAL_FAST, SCAN_INTERVAL_SLOW
2325

2426

2527
@dataclass
26-
class BSBLanCoordinatorData:
27-
"""BSBLan data stored in the Home Assistant data object."""
28+
class BSBLanFastData:
29+
"""BSBLan fast-polling data."""
2830

2931
state: State
3032
sensor: Sensor
3133
dhw: HotWaterState
3234

3335

34-
class BSBLanUpdateCoordinator(DataUpdateCoordinator[BSBLanCoordinatorData]):
35-
"""The BSB-Lan update coordinator."""
36+
@dataclass
37+
class BSBLanSlowData:
38+
"""BSBLan slow-polling data."""
39+
40+
dhw_config: HotWaterConfig | None = None
41+
dhw_schedule: HotWaterSchedule | None = None
42+
43+
44+
class BSBLanCoordinator[T](DataUpdateCoordinator[T]):
45+
"""Base BSB-Lan coordinator."""
3646

3747
config_entry: ConfigEntry
3848

@@ -41,44 +51,122 @@ def __init__(
4151
hass: HomeAssistant,
4252
config_entry: ConfigEntry,
4353
client: BSBLAN,
54+
name: str,
55+
update_interval: timedelta,
4456
) -> None:
4557
"""Initialize the BSB-Lan coordinator."""
4658
super().__init__(
4759
hass,
4860
logger=LOGGER,
4961
config_entry=config_entry,
50-
name=f"{DOMAIN}_{config_entry.data[CONF_HOST]}",
51-
update_interval=self._get_update_interval(),
62+
name=name,
63+
update_interval=update_interval,
5264
)
5365
self.client = client
5466

67+
68+
class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]):
69+
"""The BSB-Lan fast update coordinator for frequently changing data."""
70+
71+
def __init__(
72+
self,
73+
hass: HomeAssistant,
74+
config_entry: ConfigEntry,
75+
client: BSBLAN,
76+
) -> None:
77+
"""Initialize the BSB-Lan fast coordinator."""
78+
super().__init__(
79+
hass,
80+
config_entry,
81+
client,
82+
name=f"{DOMAIN}_fast_{config_entry.data[CONF_HOST]}",
83+
update_interval=self._get_update_interval(),
84+
)
85+
5586
def _get_update_interval(self) -> timedelta:
5687
"""Get the update interval with a random offset.
5788
58-
Use the default scan interval and add a random number of seconds to avoid timeouts when
89+
Add a random number of seconds to avoid timeouts when
5990
the BSB-Lan device is already/still busy retrieving data,
6091
e.g. for MQTT or internal logging.
6192
"""
62-
return SCAN_INTERVAL + timedelta(seconds=randint(1, 8))
93+
return SCAN_INTERVAL_FAST + timedelta(seconds=randint(1, 8))
6394

64-
async def _async_update_data(self) -> BSBLanCoordinatorData:
65-
"""Get state and sensor data from BSB-Lan device."""
95+
async def _async_update_data(self) -> BSBLanFastData:
96+
"""Fetch fast-changing data from the BSB-Lan device."""
6697
try:
67-
# initialize the client, this is cached and will only be called once
68-
await self.client.initialize()
69-
98+
# Client is already initialized in async_setup_entry
99+
# Fetch fast-changing data (state, sensor, DHW state)
70100
state = await self.client.state()
71101
sensor = await self.client.sensor()
72102
dhw = await self.client.hot_water_state()
103+
73104
except BSBLANAuthError as err:
74105
raise ConfigEntryAuthFailed(
75106
"Authentication failed for BSB-Lan device"
76107
) from err
77108
except BSBLANConnectionError as err:
78-
host = self.config_entry.data[CONF_HOST] if self.config_entry else "unknown"
109+
host = self.config_entry.data[CONF_HOST]
79110
raise UpdateFailed(
80111
f"Error while establishing connection with BSB-Lan device at {host}"
81112
) from err
82113

114+
# Update the interval with random jitter for next update
83115
self.update_interval = self._get_update_interval()
84-
return BSBLanCoordinatorData(state=state, sensor=sensor, dhw=dhw)
116+
117+
return BSBLanFastData(
118+
state=state,
119+
sensor=sensor,
120+
dhw=dhw,
121+
)
122+
123+
124+
class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]):
125+
"""The BSB-Lan slow update coordinator for infrequently changing data."""
126+
127+
def __init__(
128+
self,
129+
hass: HomeAssistant,
130+
config_entry: ConfigEntry,
131+
client: BSBLAN,
132+
) -> None:
133+
"""Initialize the BSB-Lan slow coordinator."""
134+
super().__init__(
135+
hass,
136+
config_entry,
137+
client,
138+
name=f"{DOMAIN}_slow_{config_entry.data[CONF_HOST]}",
139+
update_interval=SCAN_INTERVAL_SLOW,
140+
)
141+
142+
async def _async_update_data(self) -> BSBLanSlowData:
143+
"""Fetch slow-changing data from the BSB-Lan device."""
144+
try:
145+
# Client is already initialized in async_setup_entry
146+
# Fetch slow-changing configuration data
147+
dhw_config = await self.client.hot_water_config()
148+
dhw_schedule = await self.client.hot_water_schedule()
149+
150+
except AttributeError:
151+
# Device does not support DHW functionality
152+
LOGGER.debug(
153+
"DHW (Domestic Hot Water) not available on device at %s",
154+
self.config_entry.data[CONF_HOST],
155+
)
156+
return BSBLanSlowData()
157+
except (BSBLANConnectionError, BSBLANAuthError) as err:
158+
# If config update fails, keep existing data
159+
LOGGER.debug(
160+
"Failed to fetch DHW config from %s: %s",
161+
self.config_entry.data[CONF_HOST],
162+
err,
163+
)
164+
if self.data:
165+
return self.data
166+
# First fetch failed, return empty data
167+
return BSBLanSlowData()
168+
169+
return BSBLanSlowData(
170+
dhw_config=dhw_config,
171+
dhw_schedule=dhw_schedule,
172+
)

homeassistant/components/bsblan/diagnostics.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,28 @@ async def async_get_config_entry_diagnostics(
1515
"""Return diagnostics for a config entry."""
1616
data = entry.runtime_data
1717

18-
return {
18+
# Build diagnostic data from both coordinators
19+
diagnostics = {
1920
"info": data.info.to_dict(),
2021
"device": data.device.to_dict(),
21-
"coordinator_data": {
22-
"state": data.coordinator.data.state.to_dict(),
23-
"sensor": data.coordinator.data.sensor.to_dict(),
22+
"fast_coordinator_data": {
23+
"state": data.fast_coordinator.data.state.to_dict(),
24+
"sensor": data.fast_coordinator.data.sensor.to_dict(),
25+
"dhw": data.fast_coordinator.data.dhw.to_dict(),
2426
},
2527
"static": data.static.to_dict(),
2628
}
29+
30+
# Add DHW config and schedule from slow coordinator if available
31+
if data.slow_coordinator.data:
32+
slow_data = {}
33+
if data.slow_coordinator.data.dhw_config:
34+
slow_data["dhw_config"] = data.slow_coordinator.data.dhw_config.to_dict()
35+
if data.slow_coordinator.data.dhw_schedule:
36+
slow_data["dhw_schedule"] = (
37+
data.slow_coordinator.data.dhw_schedule.to_dict()
38+
)
39+
if slow_data:
40+
diagnostics["slow_coordinator_data"] = slow_data
41+
42+
return diagnostics

homeassistant/components/bsblan/entity.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,17 @@
1111

1212
from . import BSBLanData
1313
from .const import DOMAIN
14-
from .coordinator import BSBLanUpdateCoordinator
14+
from .coordinator import BSBLanCoordinator, BSBLanFastCoordinator, BSBLanSlowCoordinator
1515

1616

17-
class BSBLanEntity(CoordinatorEntity[BSBLanUpdateCoordinator]):
18-
"""Defines a base BSBLan entity."""
17+
class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]):
18+
"""Base BSBLan entity with common device info setup."""
1919

2020
_attr_has_entity_name = True
2121

22-
def __init__(self, coordinator: BSBLanUpdateCoordinator, data: BSBLanData) -> None:
23-
"""Initialize BSBLan entity."""
24-
super().__init__(coordinator, data)
22+
def __init__(self, coordinator: _T, data: BSBLanData) -> None:
23+
"""Initialize BSBLan entity with device info."""
24+
super().__init__(coordinator)
2525
host = coordinator.config_entry.data["host"]
2626
mac = data.device.MAC
2727
self._attr_device_info = DeviceInfo(
@@ -33,3 +33,33 @@ def __init__(self, coordinator: BSBLanUpdateCoordinator, data: BSBLanData) -> No
3333
sw_version=data.device.version,
3434
configuration_url=f"http://{host}",
3535
)
36+
37+
38+
class BSBLanEntity(BSBLanEntityBase[BSBLanFastCoordinator]):
39+
"""Defines a base BSBLan entity using the fast coordinator."""
40+
41+
def __init__(self, coordinator: BSBLanFastCoordinator, data: BSBLanData) -> None:
42+
"""Initialize BSBLan entity."""
43+
super().__init__(coordinator, data)
44+
45+
46+
class BSBLanDualCoordinatorEntity(BSBLanEntity):
47+
"""Entity that listens to both fast and slow coordinators."""
48+
49+
def __init__(
50+
self,
51+
fast_coordinator: BSBLanFastCoordinator,
52+
slow_coordinator: BSBLanSlowCoordinator,
53+
data: BSBLanData,
54+
) -> None:
55+
"""Initialize BSBLan entity with both coordinators."""
56+
super().__init__(fast_coordinator, data)
57+
self.slow_coordinator = slow_coordinator
58+
59+
async def async_added_to_hass(self) -> None:
60+
"""When entity is added to hass."""
61+
await super().async_added_to_hass()
62+
# Also listen to slow coordinator updates
63+
self.async_on_remove(
64+
self.slow_coordinator.async_add_listener(self._handle_coordinator_update)
65+
)

homeassistant/components/bsblan/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"integration_type": "device",
88
"iot_class": "local_polling",
99
"loggers": ["bsblan"],
10-
"requirements": ["python-bsblan==2.1.0"],
10+
"requirements": ["python-bsblan==3.1.0"],
1111
"zeroconf": [
1212
{
1313
"name": "bsb-lan*",

0 commit comments

Comments
 (0)