Skip to content

Commit aa53d7b

Browse files
authored
Use long lived TCP connection to hub and add heartbeat/ping sensor (#50)
* Use a long lived TCP connection to the hub Instead of creating a new connection for each HTTP request, keep a long lived TCP connection alive and use it for all requests. This should use fewer resources on the hub and hopefully avoid stability problems that can occur when the hub has been up for many months. * Add heartbeat sensor that pings the wunda hub Periodically ping the hub to check it's still responding. This should help keep the hub's network interface awake. * Add missing requirement for tests * Remove unnecessary logging * Treat empty values from syncvalues as None Elsewhere it's assumed that unavailable values are either not set in state dictionary or are None. Where empty strings are used it's logging some warnings about float conversions which aren't needed.
1 parent 1cfdb0e commit aa53d7b

File tree

11 files changed

+195
-40
lines changed

11 files changed

+195
-40
lines changed

custom_components/wundasmart/__init__.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import aiohttp
77
import logging
88
from typing import Final
9+
from contextlib import AbstractAsyncContextManager
910

1011
from homeassistant.config_entries import ConfigEntry
1112
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_SCAN_INTERVAL, Platform
@@ -14,15 +15,16 @@
1415
from homeassistant.helpers.device_registry import DeviceInfo
1516

1617
from .const import *
17-
from .session import get_session
18+
from .session import get_persistent_session
1819
from .pywundasmart import get_devices
1920

2021
_LOGGER = logging.getLogger(__name__)
2122

2223
PLATFORMS: Final[list[Platform]] = [
2324
Platform.CLIMATE,
2425
Platform.WATER_HEATER,
25-
Platform.SENSOR
26+
Platform.SENSOR,
27+
Platform.BINARY_SENSOR
2628
]
2729

2830

@@ -32,7 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
3234
wunda_ip = entry.data[CONF_HOST]
3335
wunda_user = entry.data[CONF_USERNAME]
3436
wunda_pass = entry.data[CONF_PASSWORD]
35-
update_interval = timedelta(seconds=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL))
37+
update_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
3638
connect_timeout = entry.options.get(CONF_CONNECT_TIMEOUT, DEFAULT_CONNECT_TIMEOUT)
3739
read_timeout = entry.options.get(CONF_READ_TIMEOUT, DEFAULT_READ_TIMEOUT)
3840
timeout = aiohttp.ClientTimeout(sock_connect=connect_timeout, sock_read=read_timeout)
@@ -74,7 +76,13 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non
7476
class WundasmartDataUpdateCoordinator(DataUpdateCoordinator):
7577
"""Class to manage fetching data from WundaSmart API."""
7678

77-
def __init__(self, hass, wunda_ip, wunda_user, wunda_pass, update_interval, timeout):
79+
def __init__(self,
80+
hass: HomeAssistant,
81+
wunda_ip: str,
82+
wunda_user: str,
83+
wunda_pass: str,
84+
update_interval: int,
85+
timeout: aiohttp.ClientTimeout):
7886
"""Initialize."""
7987
self._hass = hass
8088
self._wunda_ip = wunda_ip
@@ -86,16 +94,20 @@ def __init__(self, hass, wunda_ip, wunda_user, wunda_pass, update_interval, time
8694
self._sw_version = None
8795
self._hw_version = None
8896
self._timeout = timeout
97+
self._keepalive_timeout = update_interval * 2
8998

90-
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval)
99+
super().__init__(hass,
100+
_LOGGER,
101+
name=DOMAIN,
102+
update_interval=timedelta(seconds=update_interval))
91103

92104
async def _async_update_data(self):
93105
attempts = 0
94106
max_attempts = 5
95107
while attempts < max_attempts:
96108
attempts += 1
97109

98-
async with get_session(self._wunda_ip) as session:
110+
async with self.get_session() as session:
99111
result = await get_devices(
100112
session,
101113
self._wunda_ip,
@@ -138,6 +150,10 @@ async def _async_update_data(self):
138150

139151
return self._devices
140152

153+
def get_session(self) -> AbstractAsyncContextManager:
154+
"""Context manager for getting aiohttp session for any request made to the Wunda hub."""
155+
return get_persistent_session(wunda_ip=self._wunda_ip, keepalive_timeout=self._keepalive_timeout)
156+
141157
@property
142158
def device_sn(self):
143159
return self._device_sn
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from homeassistant.core import HomeAssistant
2+
from homeassistant.helpers.entity_platform import AddEntitiesCallback
3+
from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass
4+
from homeassistant.config_entries import ConfigEntry
5+
from homeassistant.helpers.event import async_track_time_interval
6+
from homeassistant.const import CONF_HOST
7+
from datetime import timedelta, datetime
8+
from . import WundasmartDataUpdateCoordinator
9+
from .const import DOMAIN, CONF_PING_INTERVAL, DEFAULT_PING_INTERVAL
10+
from icmplib import async_ping, NameLookupError
11+
import logging
12+
13+
_LOGGER = logging.getLogger(__name__)
14+
15+
ICMP_TIMEOUT = 1
16+
17+
18+
async def async_setup_entry(
19+
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
20+
) -> None:
21+
coordinator: WundasmartDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
22+
wunda_ip: str = entry.data[CONF_HOST]
23+
ping_interval = entry.options.get(CONF_PING_INTERVAL, DEFAULT_PING_INTERVAL)
24+
async_add_entities([WundaHeartbeatSensor(coordinator, wunda_ip, ping_interval)])
25+
26+
27+
class WundaHeartbeatSensor(BinarySensorEntity):
28+
"""Heartbeat sensor that pings the Wunda hub periodically."""
29+
30+
def __init__(self, coordinator, wunda_ip, poll_interval):
31+
super().__init__()
32+
self._attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
33+
self._attr_should_poll = False
34+
self._attr_device_info = coordinator.device_info
35+
self._attr_name = (coordinator.device_info or {}).get("name", "Smart HubSwitch") + " Heartbeat"
36+
if coordinator.device_sn:
37+
self._attr_unique_id = f"wunda.{coordinator.device_sn}.heartbeat"
38+
self._wunda_ip = wunda_ip
39+
self._poll_interval = timedelta(seconds=poll_interval)
40+
self._unsub_timer = None
41+
self._state = False
42+
self._attributes = {}
43+
44+
@property
45+
def is_on(self):
46+
return self._state
47+
48+
@property
49+
def extra_state_attributes(self):
50+
return self._attributes
51+
52+
async def async_added_to_hass(self):
53+
await super().async_added_to_hass()
54+
55+
# Schedule periodic updates
56+
self._unsub_timer = async_track_time_interval(
57+
self.hass, self._async_poll, self._poll_interval
58+
)
59+
60+
# Do first poll immediately
61+
await self._async_poll(None)
62+
63+
async def async_will_remove_from_hass(self):
64+
await super().async_will_remove_from_hass()
65+
if self._unsub_timer:
66+
self._unsub_timer()
67+
self._unsub_timer = None
68+
69+
async def _async_poll(self, now):
70+
await self.async_update_ha_state(force_refresh=True)
71+
72+
async def async_update(self):
73+
try:
74+
data = await async_ping(
75+
self._wunda_ip,
76+
count=1,
77+
timeout=ICMP_TIMEOUT,
78+
privileged=False
79+
)
80+
except NameLookupError:
81+
_LOGGER.debug("Error resolving host: %s", self._wunda_ip)
82+
self._state = False
83+
self._attributes = {}
84+
return
85+
except Exception as err:
86+
_LOGGER.debug("Ping failed for %s: %s", self._wunda_ip, err)
87+
self._state = False
88+
self._attributes = {}
89+
return
90+
91+
self._state = data.is_alive
92+
if not self._state:
93+
self._attributes = {}
94+
return
95+
96+
self._attributes = {
97+
"lastseen": datetime.now().isoformat(),
98+
"min": data.min_rtt,
99+
"max": data.max_rtt,
100+
"avg": data.avg_rtt,
101+
"jitter": data.jitter,
102+
}

custom_components/wundasmart/climate.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
"""Support for WundaSmart climate."""
22
from __future__ import annotations
33

4-
import math
54
import logging
65
import aiohttp
76
from typing import Any
87
import voluptuous as vol
98

10-
from homeassistant.components.climate import (
11-
ClimateEntity,
9+
from homeassistant.components.climate import ClimateEntity
10+
from homeassistant.components.climate.const import (
1211
ClimateEntityFeature,
1312
HVACAction,
1413
HVACMode,
@@ -29,7 +28,6 @@
2928

3029
from . import WundasmartDataUpdateCoordinator
3130
from .pywundasmart import send_command, set_register, get_room_id_from_device
32-
from .session import get_session
3331
from .const import *
3432

3533
_LOGGER = logging.getLogger(__name__)
@@ -165,7 +163,7 @@ def __trvs(self):
165163
for device in self.coordinator.data.values():
166164
if device.get("device_type") == "TRV":
167165
room_id = get_room_id_from_device(device)
168-
if int(room_id) == int(self._wunda_id):
166+
if room_id is not None and int(room_id) == int(self._wunda_id):
169167
yield device
170168

171169
def __set_current_temperature(self):
@@ -301,7 +299,7 @@ async def async_added_to_hass(self) -> None:
301299

302300
async def async_set_temperature(self, temperature, **kwargs):
303301
# Set the new target temperature
304-
async with get_session(self._wunda_ip) as session:
302+
async with self.coordinator.get_session() as session:
305303
await send_command(
306304
session,
307305
self._wunda_ip,
@@ -322,7 +320,7 @@ async def async_set_temperature(self, temperature, **kwargs):
322320
async def async_set_hvac_mode(self, hvac_mode: HVACMode):
323321
if hvac_mode == HVACMode.AUTO:
324322
# Set to programmed mode
325-
async with get_session(self._wunda_ip) as session:
323+
async with self.coordinator.get_session() as session:
326324
await send_command(
327325
session,
328326
self._wunda_ip,
@@ -338,7 +336,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode):
338336
})
339337
elif hvac_mode == HVACMode.HEAT:
340338
# Set the target temperature to the t_hi preset temp
341-
async with get_session(self._wunda_ip) as session:
339+
async with self.coordinator.get_session() as session:
342340
await send_command(
343341
session,
344342
self._wunda_ip,
@@ -354,7 +352,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode):
354352
})
355353
elif hvac_mode == HVACMode.OFF:
356354
# Set the target temperature to zero
357-
async with get_session(self._wunda_ip) as session:
355+
async with self.coordinator.get_session() as session:
358356
await send_command(
359357
session,
360358
self._wunda_ip,
@@ -382,7 +380,7 @@ async def async_set_preset_mode(self, preset_mode) -> None:
382380

383381
t_preset = float(self.__state[state_key])
384382

385-
async with get_session(self._wunda_ip) as session:
383+
async with self.coordinator.get_session() as session:
386384
await send_command(
387385
session,
388386
self._wunda_ip,
@@ -414,7 +412,7 @@ async def async_set_preset_temperature(self, service_data: ServiceCall) -> None:
414412
preset = service_data.data["preset"]
415413
temperature = service_data.data["temperature"]
416414

417-
async with get_session(self._wunda_ip) as session:
415+
async with self.coordinator.get_session() as session:
418416
await set_register(
419417
session,
420418
self._wunda_ip,

custom_components/wundasmart/config_flow.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44

55
from homeassistant import config_entries, core, exceptions
66
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL
7-
from homeassistant.data_entry_flow import FlowResult
7+
from homeassistant.config_entries import ConfigFlowResult
88
from homeassistant.core import callback
99

1010
from .const import *
11-
from .session import get_session
11+
from .session import get_transient_session
1212
from .pywundasmart import get_devices
1313

1414
STEP_USER_DATA_SCHEMA = vol.Schema(
@@ -32,7 +32,7 @@ def __init__(self, hass, wunda_ip, wunda_user, wunda_pass):
3232

3333
async def authenticate(self):
3434
"""Wundasmart Hub class authenticate."""
35-
async with get_session(self._wunda_ip) as session:
35+
async with get_transient_session(self._wunda_ip) as session:
3636
return await get_devices(
3737
session,
3838
self._wunda_ip,
@@ -137,7 +137,7 @@ class OptionsFlow(config_entries.OptionsFlow):
137137
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
138138
self.config_entry = config_entry
139139

140-
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> FlowResult:
140+
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
141141
if user_input is not None:
142142
return self.async_create_entry(title="", data=user_input)
143143

@@ -156,6 +156,11 @@ async def async_step_init(self, user_input: dict[str, Any] | None = None) -> Flo
156156
CONF_READ_TIMEOUT,
157157
default=self.config_entry.options.get(
158158
CONF_READ_TIMEOUT, DEFAULT_READ_TIMEOUT
159+
)): int,
160+
vol.Optional(
161+
CONF_PING_INTERVAL,
162+
default=self.config_entry.options.get(
163+
CONF_PING_INTERVAL, DEFAULT_PING_INTERVAL
159164
)): int
160165
}
161166

custom_components/wundasmart/const.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Constants for the wundasmart integration."""
22
from dataclasses import dataclass
3-
from homeassistant.components.climate import (
3+
from homeassistant.components.climate.const import (
44
PRESET_ECO,
55
PRESET_COMFORT
66
)
@@ -9,10 +9,12 @@
99

1010
CONF_CONNECT_TIMEOUT = "connect_timeout"
1111
CONF_READ_TIMEOUT = "read_timeout"
12+
CONF_PING_INTERVAL = "ping_interval"
1213

1314
DEFAULT_SCAN_INTERVAL = 300
1415
DEFAULT_CONNECT_TIMEOUT = 5
1516
DEFAULT_READ_TIMEOUT = 5
17+
DEFAULT_PING_INTERVAL = 180
1618

1719
@dataclass
1820
class DeviceIdRanges:

0 commit comments

Comments
 (0)