From 826d4e04381c490cc7c06be4ea53be278b0d08e5 Mon Sep 17 00:00:00 2001 From: lifespirit Date: Wed, 2 Sep 2026 16:06:32 +0300 Subject: [PATCH] fix: restore reliable Bluetooth control on Home Assistant 2026 --- custom_components/ha_tion_btle/__init__.py | 84 ++++++++++++++----- custom_components/ha_tion_btle/climate.py | 21 ++--- custom_components/ha_tion_btle/config_flow.py | 30 +++++-- custom_components/ha_tion_btle/manifest.json | 5 +- .../ha_tion_btle/manifest.json.tpl | 3 +- 5 files changed, 96 insertions(+), 47 deletions(-) diff --git a/custom_components/ha_tion_btle/__init__.py b/custom_components/ha_tion_btle/__init__.py index df2f8cf..e9063c1 100644 --- a/custom_components/ha_tion_btle/__init__.py +++ b/custom_components/ha_tion_btle/__init__.py @@ -1,19 +1,22 @@ """The Tion breezer component.""" from __future__ import annotations -from bleak.backends.device import BLEDevice +import asyncio import datetime import logging import math -from datetime import timedelta +from collections.abc import Awaitable, Callable from functools import cached_property +from bleak import BleakClient +from bleak.backends.device import BLEDevice +from bleak_retry_connector import BLEAK_RETRY_EXCEPTIONS, establish_connection import tion_btle from homeassistant.components import bluetooth from homeassistant.components.bluetooth import BluetoothCallbackMatcher from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from tion_btle.tion import Tion, MaxTriesExceededError +from tion_btle.tion import MaxTriesExceededError, Tion from .const import DOMAIN, TION_SCHEMA, CONF_KEEP_ALIVE, CONF_AWAY_TEMP, CONF_MAC, PLATFORMS from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -64,10 +67,16 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry): except KeyError: pass - # delay before next update if we got btle.BTLEDisconnectError - self._delay: int = 600 + # A short backoff keeps a transient BLE failure from making controls + # appear broken for ten minutes. + self._delay: int = 30 + self._operation_lock = asyncio.Lock() - self.__tion: Tion = self.getTion(self.model, btle_device) + self.__tion: Tion = self.getTion( + self.model, + btle_device, + connection_factory=self._establish_connection, + ) self.__keep_alive = datetime.timedelta(seconds=self.__keep_alive) self._delay = datetime.timedelta(seconds=self._delay) self.rssi: int = 0 @@ -112,17 +121,23 @@ async def async_update_state(self): response: dict[str, str | bool | int] = {} try: - response = await self.__tion.get() + async with self._operation_lock: + self._refresh_btle_device() + response = await self.__tion.get() self.update_interval = self.__keep_alive except MaxTriesExceededError as e: - _LOGGER.critical("Got exception %s", str(e)) - _LOGGER.critical("Will delay next check") + _LOGGER.warning("Could not connect to Tion: %s", str(e)) self.update_interval = self._delay - raise UpdateFailed("MaxTriesExceededError") + raise UpdateFailed("Could not connect to Tion") from e + except BLEAK_RETRY_EXCEPTIONS as e: + _LOGGER.warning("Bluetooth operation failed: %s", str(e)) + self.update_interval = self._delay + raise UpdateFailed(f"Bluetooth operation failed: {e}") from e except Exception as e: - _LOGGER.critical(f"{response=}, {e=}") - raise e + _LOGGER.warning("Could not update Tion state: %s", e, exc_info=True) + self.update_interval = self._delay + raise UpdateFailed(f"Could not update Tion state: {e}") from e response["is_on"]: bool = self._decode_state(response["state"]) response["heater"]: bool = self._decode_state(response["heater"]) @@ -152,12 +167,19 @@ async def set(self, **kwargs): args = ', '.join('%s=%r' % x for x in kwargs.items()) _LOGGER.info("Need to set: " + args) - await self.__tion.set(kwargs) - self.data.update(original_args) - self.async_update_listeners() + async with self._operation_lock: + self._refresh_btle_device() + await self.__tion.set(kwargs) + self.update_interval = self.__keep_alive + self.data.update(original_args) + self.async_update_listeners() @staticmethod - def getTion(model: str, mac: str | BLEDevice) -> tion_btle.TionS3 | tion_btle.TionLite | tion_btle.TionS4: + def getTion( + model: str, + mac: str | BLEDevice, + connection_factory: Callable[[str | BLEDevice], Awaitable[BleakClient]] | None = None, + ) -> tion_btle.TionS3 | tion_btle.TionLite | tion_btle.TionS4: if model == 'S3': from tion_btle.s3 import TionS3 as Breezer elif model == 'S4': @@ -166,13 +188,31 @@ def getTion(model: str, mac: str | BLEDevice) -> tion_btle.TionS3 | tion_btle.Ti from tion_btle.lite import TionLite as Breezer else: raise NotImplementedError("Model '%s' is not supported!" % model) - return Breezer(mac) - - async def connect(self): - return await self.__tion.connect() + return Breezer(mac, connection_factory=connection_factory) + + async def _establish_connection(self, device: str | BLEDevice) -> BleakClient: + """Establish a reliable connection using Home Assistant's BLE path.""" + if not isinstance(device, BLEDevice): + raise ValueError("Home Assistant must provide a BLEDevice") + return await establish_connection( + BleakClient, + device, + self.config.get("name", self.config[CONF_MAC]), + max_attempts=3, + ) - async def disconnect(self): - return await self.__tion.disconnect() + def _refresh_btle_device(self) -> None: + """Select the best currently available adapter or Bluetooth proxy.""" + device = bluetooth.async_ble_device_from_address( + self.hass, + self.config[CONF_MAC], + connectable=True, + ) + if device is None: + raise UpdateFailed( + f"No connectable Bluetooth path to {self.config[CONF_MAC]}" + ) + self.__tion.update_btle_device(device) @property def device_info(self): diff --git a/custom_components/ha_tion_btle/climate.py b/custom_components/ha_tion_btle/climate.py index 17fb409..08b5f76 100644 --- a/custom_components/ha_tion_btle/climate.py +++ b/custom_components/ha_tion_btle/climate.py @@ -118,13 +118,9 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode): elif hvac_mode == HVACMode.HEAT: saved_target_temp = self.target_temperature - try: - await self.coordinator.connect() - await self._async_set_state(heater=True, is_on=True) - if self.hvac_mode == HVACMode.FAN_ONLY: - await self.async_set_temperature(**{ATTR_TEMPERATURE: saved_target_temp}) - finally: - await self.coordinator.disconnect() + await self._async_set_state(heater=True, is_on=True) + if self.hvac_mode == HVACMode.FAN_ONLY: + await self.async_set_temperature(**{ATTR_TEMPERATURE: saved_target_temp}) elif hvac_mode == HVACMode.FAN_ONLY: await self._async_set_state(heater=False, is_on=True) @@ -172,14 +168,9 @@ async def async_set_preset_mode(self, preset_mode: str): self._saved_fan_mode = None self._attr_preset_mode = preset_mode - try: - await self.coordinator.connect() - for a in actions: - await a[0](**a[1]) - self._attr_preset_mode = preset_mode - self._handle_coordinator_update() - finally: - await self.coordinator.disconnect() + for a in actions: + await a[0](**a[1]) + self._attr_preset_mode = preset_mode self._handle_coordinator_update() diff --git a/custom_components/ha_tion_btle/config_flow.py b/custom_components/ha_tion_btle/config_flow.py index 53af1a9..e3722e1 100644 --- a/custom_components/ha_tion_btle/config_flow.py +++ b/custom_components/ha_tion_btle/config_flow.py @@ -8,11 +8,14 @@ import bleak import tion_btle import voluptuous as vol +from bleak import BleakClient +from bleak.backends.device import BLEDevice +from bleak_retry_connector import establish_connection from homeassistant import config_entries from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry -from homeassistant.core import callback, async_get_hass +from homeassistant.core import callback from tion_btle.tion import Tion from .const import DOMAIN, TION_SCHEMA, CONF_MAC @@ -93,15 +96,28 @@ def config(self) -> dict: pass return data - @staticmethod - def getTion(model: str, mac: str) -> tion_btle.TionS3 | tion_btle.TionLite | tion_btle.TionS4: + def getTion(self, model: str, mac: str) -> tion_btle.TionS3 | tion_btle.TionLite | tion_btle.TionS4: - btle_device = bluetooth.async_ble_device_from_address(hass=async_get_hass(), address=mac, connectable=True) + btle_device = bluetooth.async_ble_device_from_address( + hass=self.hass, + address=mac, + connectable=True, + ) if btle_device is None: message = f"Could not find device with {mac=}" _LOGGER.critical(f"getTion: {message}") raise bleak.BleakError(message) + async def connection_factory(device: str | BLEDevice) -> BleakClient: + if not isinstance(device, BLEDevice): + raise ValueError("Home Assistant must provide a BLEDevice") + return await establish_connection( + BleakClient, + device, + mac, + max_attempts=3, + ) + if model == 'S3': from tion_btle.s3 import TionS3 as Breezer elif model == 'S4': @@ -110,13 +126,13 @@ def getTion(model: str, mac: str) -> tion_btle.TionS3 | tion_btle.TionLite | tio from tion_btle.lite import TionLite as Breezer else: raise NotImplementedError("Model '%s' is not supported!" % model) - return Breezer(btle_device) + return Breezer(btle_device, connection_factory=connection_factory) class TionConfigFlow(TionFlow, config_entries.ConfigFlow, domain=DOMAIN): """Initial setup.""" VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL def __init__(self): super().__init__() @@ -146,7 +162,7 @@ async def async_step_user(self, input=None): _LOGGER.debug(input) try: _tion: Tion = self.getTion(input['model'], input['mac']) - result = _tion.get() + result = await _tion.get() except Exception as e: _LOGGER.error("Could not get data from breezer. result is %s, error: %s" % (result, str(e))) return self.async_show_form(step_id='add_failed') diff --git a/custom_components/ha_tion_btle/manifest.json b/custom_components/ha_tion_btle/manifest.json index 717f201..522c77c 100644 --- a/custom_components/ha_tion_btle/manifest.json +++ b/custom_components/ha_tion_btle/manifest.json @@ -4,14 +4,15 @@ "documentation": "https://github.com/TionAPI/HA-tion/wiki", "dependencies": [ "bluetooth", + "bluetooth_adapters", "fan" ], "requirements": [ - "tion-btle==3.3.6" + "https://github.com/lifespirit/tion_python/archive/00b5527fc7194b66a58afcf939b244ed6d6dcff6.zip#tion-btle==3.3.7.dev0" ], "codeowners": [ "@IATkachenko" ], "config_flow": true, - "version": "v4.2.0" + "version": "v4.3.0-dev0" } diff --git a/custom_components/ha_tion_btle/manifest.json.tpl b/custom_components/ha_tion_btle/manifest.json.tpl index 0f7b250..6541629 100644 --- a/custom_components/ha_tion_btle/manifest.json.tpl +++ b/custom_components/ha_tion_btle/manifest.json.tpl @@ -4,10 +4,11 @@ "documentation": "https://github.com/TionAPI/HA-tion/wiki", "dependencies": [ "bluetooth", + "bluetooth_adapters", "fan" ], "requirements": [ - "tion-btle==3.3.6" + "https://github.com/lifespirit/tion_python/archive/00b5527fc7194b66a58afcf939b244ed6d6dcff6.zip#tion-btle==3.3.7.dev0" ], "codeowners": [ "@IATkachenko"