diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b379437..16d1483 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,13 +72,10 @@ jobs: pip install -r requirements.txt - - name: Set version + - name: Build env: VERSION: ${{ needs.changes.outputs.tag }} - run: sed --in-place -e "s/%%%VERSION%%%/${VERSION##v}/" setup.py - - - name: Build - run: python setup.py sdist bdist_wheel + run: TION_BTLE_VERSION="${VERSION##v}" python setup.py sdist bdist_wheel - name: Publish env: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 37f8346..32cc7c1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,7 +1,7 @@ name: CI tests on: push: - branches: [ master ] + branches: [ master, HA2026_fix ] pull_request: branches: [ master ] types: [opened, synchronize] @@ -15,16 +15,16 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: ['3.9', '3.10'] + python: ['3.10', '3.12', '3.14'] type: [unit] # Steps represent a sequence of tasks that will be executed as part of the job steps: - name: Checkout - uses: actions/checkout@v2.3.4 + uses: actions/checkout@v4 - name: Prepare python ${{ matrix.python }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} diff --git a/README.md b/README.md index 765c0b6..e703a07 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ ![CI tests](https://github.com/TionAPI/tion_python/workflows/CI%20tests/badge.svg?branch=master&event=push) # About -This module will allow you to control your Tion S3 or Tion Lite breezer via bluetooth. +This module will allow you to control your Tion S3, S4 or Tion Lite breezer via Bluetooth. If you want to use MagicAir API please follow https://github.com/airens/tion # Installation @@ -84,3 +84,14 @@ To pair device turn breezer to pairing mode and call ```python await device.pair() ``` + +## Connection factory + +Integrations that manage Bluetooth adapters or proxies may pass an asynchronous +`connection_factory` to the constructor. It must accept the current MAC address +or `BLEDevice` and return an already connected `BleakClient`. A fresh client is +used for every connection session. + +```python +device = Breezer(ble_device, connection_factory=connect) +``` diff --git a/requirements.txt b/requirements.txt index 57b776e..a110c2a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -bleak>=0.14.3 +bleak>=2.1.1 diff --git a/setup.py b/setup.py index 402b44b..51a9585 100644 --- a/setup.py +++ b/setup.py @@ -5,16 +5,21 @@ here = os.path.abspath(os.path.dirname(__file__)) -req = [] -with open("requirements.txt") as f: - req.append(f.read()) +with open(os.path.join(here, "requirements.txt"), encoding="utf-8") as f: + requirements = [ + line.strip() + for line in f + if line.strip() and not line.lstrip().startswith("#") + ] + +version = os.environ.get("TION_BTLE_VERSION", "3.3.7.dev0") setup( name='tion_btle', - version='%%%VERSION%%%', + version=version, long_description="Module for working with Tion breezers", url='https://github.com/TionAPI/tion_python/tree/dev', - install_requires=[req], + install_requires=requirements, description='Python module for interacting with Tion breezers', packages=find_packages(), ) diff --git a/tests/unit/test_tion.py b/tests/unit/test_tion.py index 9dd64b1..a8aadee 100644 --- a/tests/unit/test_tion.py +++ b/tests/unit/test_tion.py @@ -125,3 +125,99 @@ def test_mac(instance): target = 'foo' t_tion = instance(target) assert t_tion.mac == target + + +@pytest.mark.asyncio +async def test_new_bleak_client_is_created_for_each_connection(): + clients = [] + + class FakeBleakClient: + def __init__(self, device): + self.device = device + self.is_connected = False + clients.append(self) + + async def connect(self): + self.is_connected = True + return True + + async def disconnect(self): + self.is_connected = False + + with mock.patch("tion_btle.tion.BleakClient", FakeBleakClient): + t_tion = Tion("foo") + await t_tion._try_connect() + await t_tion._disconnect() + await t_tion._try_connect() + + assert len(clients) == 2 + assert clients[0] is not clients[1] + + +@pytest.mark.asyncio +async def test_direct_retry_uses_a_fresh_bleak_client(): + clients = [] + + class FakeBleakClient: + def __init__(self, device): + self.device = device + self.is_connected = False + clients.append(self) + + async def connect(self): + if len(clients) == 1: + raise exc.BleakError("first connection failed") + self.is_connected = True + return True + + with ( + mock.patch("tion_btle.tion.BleakClient", FakeBleakClient), + mock.patch("tion_btle.tion.asyncio.sleep", new=mock.AsyncMock()), + ): + await Tion("foo")._try_connect() + + assert len(clients) == 2 + assert clients[0] is not clients[1] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("instance", [Tion, TionLiteFamily, TionLite, TionS3, TionS4]) +async def test_connection_factory_uses_latest_ble_device(instance): + client = mock.MagicMock() + client.is_connected = True + connection_factory = mock.AsyncMock(return_value=client) + t_tion = instance("old-device", connection_factory=connection_factory) + + t_tion.update_btle_device("new-device") + await t_tion._try_connect() + + connection_factory.assert_awaited_once_with("new-device") + + +@pytest.mark.asyncio +async def test_connection_factory_owns_its_retry_policy(): + connection_factory = mock.AsyncMock(side_effect=exc.BleakError("failed")) + t_tion = Tion("device", connection_factory=connection_factory) + + with pytest.raises(exc.BleakError): + await t_tion._try_connect() + + connection_factory.assert_awaited_once_with("device") + + +@pytest.mark.asyncio +async def test_linux_notifications_force_bluez_start_notify(): + client = mock.MagicMock() + client.is_connected = True + client.start_notify = mock.AsyncMock() + t_tion = Tion("foo") + t_tion._btle = client + + with mock.patch("tion_btle.tion.sys.platform", "linux"): + await t_tion._enable_notifications() + + client.start_notify.assert_awaited_once_with( + t_tion.uuid_notify, + t_tion._delegation.handleNotification, + bluez={"use_start_notify": True}, + ) diff --git a/tion_btle/light_family.py b/tion_btle/light_family.py index 5b59715..94b6baf 100644 --- a/tion_btle/light_family.py +++ b/tion_btle/light_family.py @@ -8,9 +8,9 @@ from bleak.backends.device import BLEDevice if __package__ == "": - from tion_btle.tion import Tion + from tion_btle.tion import ConnectionFactory, Tion else: - from .tion import Tion + from .tion import ConnectionFactory, Tion logging.basicConfig(level=logging.DEBUG) _LOGGER = logging.getLogger(__name__) @@ -31,8 +31,12 @@ class TionLiteFamily(Tion): END_PACKET_ID = 0xc0 MAGIC_NUMBER: int = 0x3a # 58 - def __init__(self, mac: str | BLEDevice): - super().__init__(mac) + def __init__( + self, + mac: str | BLEDevice, + connection_factory: ConnectionFactory | None = None, + ): + super().__init__(mac, connection_factory=connection_factory) self._data: bytearray = bytearray() self._crc: bytearray = bytearray() self._header: bytearray = bytearray() @@ -181,4 +185,3 @@ def REQUEST_DEVICE_INFO(self) -> list: def _packages(self) -> list: """Packages for tests""" raise NotImplementedError() - diff --git a/tion_btle/lite.py b/tion_btle/lite.py index 94857ec..9f22709 100644 --- a/tion_btle/lite.py +++ b/tion_btle/lite.py @@ -5,10 +5,10 @@ from bleak.backends.device import BLEDevice if __package__ == "": - from tion_btle.tion import TionException + from tion_btle.tion import ConnectionFactory, TionException from tion_btle.light_family import TionLiteFamily else: - from .tion import TionException + from .tion import ConnectionFactory, TionException from .light_family import TionLiteFamily logging.basicConfig(level=logging.DEBUG) @@ -17,8 +17,12 @@ class TionLite(TionLiteFamily): - def __init__(self, mac: str | BLEDevice): - super().__init__(mac) + def __init__( + self, + mac: str | BLEDevice, + connection_factory: ConnectionFactory | None = None, + ): + super().__init__(mac, connection_factory=connection_factory) self._package_size: bytearray = bytearray() self._command_type: bytearray = bytearray() self._request_id: bytearray = bytearray() diff --git a/tion_btle/s3.py b/tion_btle/s3.py index 0f0a14e..1c48810 100644 --- a/tion_btle/s3.py +++ b/tion_btle/s3.py @@ -5,9 +5,9 @@ from bleak.backends.device import BLEDevice if __package__ == "": - from tion_btle.tion import Tion, TionException + from tion_btle.tion import ConnectionFactory, Tion, TionException else: - from .tion import Tion, TionException + from .tion import ConnectionFactory, Tion, TionException logging.basicConfig(level=logging.DEBUG) _LOGGER = logging.getLogger(__name__) @@ -29,8 +29,12 @@ class TionS3(Tion): command_REQUEST_PARAMS = 1 command_SET_PARAMS = 2 - def __init__(self, mac: str | BLEDevice): - super().__init__(mac) + def __init__( + self, + mac: str | BLEDevice, + connection_factory: ConnectionFactory | None = None, + ): + super().__init__(mac, connection_factory=connection_factory) # S3-specific properties self._timer: bool = False diff --git a/tion_btle/s4.py b/tion_btle/s4.py index 22084ed..f6186b7 100644 --- a/tion_btle/s4.py +++ b/tion_btle/s4.py @@ -5,10 +5,10 @@ from bleak.backends.device import BLEDevice if __package__ == "": - from tion_btle.tion import TionException + from tion_btle.tion import ConnectionFactory, TionException from tion_btle.light_family import TionLiteFamily else: - from .tion import TionException + from .tion import ConnectionFactory, TionException from .light_family import TionLiteFamily logging.basicConfig(level=logging.DEBUG) @@ -16,8 +16,12 @@ class TionS4(TionLiteFamily): - def __init__(self, mac: str | BLEDevice): - super().__init__(mac) + def __init__( + self, + mac: str | BLEDevice, + connection_factory: ConnectionFactory | None = None, + ): + super().__init__(mac, connection_factory=connection_factory) self.modes = ['outside', 'recirculation'] diff --git a/tion_btle/tion.py b/tion_btle/tion.py index c69ec03..dc75f7a 100644 --- a/tion_btle/tion.py +++ b/tion_btle/tion.py @@ -4,9 +4,11 @@ import asyncio import inspect import logging +import sys from asyncio import Semaphore -from typing import Callable, List, final +from collections.abc import Awaitable, Callable from time import localtime, strftime +from typing import List, final from bleak import BleakClient from bleak import exc @@ -14,6 +16,8 @@ _LOGGER = logging.getLogger(__name__) +ConnectionFactory = Callable[[str | BLEDevice], Awaitable[BleakClient]] + class MaxTriesExceededError(Exception): pass @@ -79,10 +83,16 @@ class Tion: uuid_notify: str = "" uuid_write: str = "" - def __init__(self, mac: str | BLEDevice): + def __init__( + self, + mac: str | BLEDevice, + connection_factory: ConnectionFactory | None = None, + ): self._mac = mac - self._btle: BleakClient = BleakClient(mac) - self._next_btle_device: BleakClient | None = None + self._ble_device: str | BLEDevice = mac + self._connection_factory = connection_factory + self._btle: BleakClient | None = None + self._next_btle_device: str | BLEDevice | None = None self._delegation = TionDelegation() self._fan_speed = 0 self._model: str = self.__class__.__name__ @@ -285,14 +295,27 @@ def _process_status(self, code: int) -> str: @final @property def connection_status(self): - status = "connected" if self._btle.is_connected else "disc" + status = "connected" if self._btle is not None and self._btle.is_connected else "disc" return status @final - @retry(retries=1, delay=2) async def _try_connect(self) -> bool: - """Tries to connect with retries""" + """Connect through an injected connector or the default Bleak path.""" self.set_new_btle_device() + if self._connection_factory is not None: + self._btle = await self._connection_factory(self._ble_device) + if not self._btle.is_connected: + raise exc.BleakError("Connection factory returned a disconnected client") + return True + + return await self._try_connect_with_bleak() + + @retry(retries=1, delay=2) + async def _try_connect_with_bleak(self) -> bool: + """Connect directly with Bleak, retrying with a fresh client.""" + # A disconnected BleakClient must not be reused. Recreating it also + # ensures that a newly selected adapter/proxy from BLEDevice is used. + self._btle = BleakClient(self._ble_device) return await self._btle.connect() @final @@ -314,8 +337,15 @@ async def _connect(self, need_notifications: bool = True): @final async def _disconnect(self): _LOGGER.debug(f"Disconnecting. {self.connection_status=}.") - if self.connection_status != "disc": - await self._btle.disconnect() + client = self._btle + try: + if client is not None and client.is_connected: + await client.disconnect() + finally: + # Never carry a client across sessions. Bleak and Home Assistant + # both expect a fresh client for the next connection attempt. + self._btle = None + self.__notifications_enabled = False async with self._semaphore: self.set_new_btle_device() @@ -325,6 +355,8 @@ async def _disconnect(self): @retry(retries=3) async def _try_write(self, request: bytearray): _LOGGER.debug(f"Writing {bytes(request).hex()} to {self.uuid_write}, {self.connection_status=}") + if self._btle is None: + raise exc.BleakError("Cannot write while disconnected") return await self._btle.write_gatt_char( self.uuid_write, request, @@ -334,8 +366,20 @@ async def _try_write(self, request: bytearray): @final async def _enable_notifications(self): _LOGGER.debug(f"Enabling notification. {self.connection_status=}") + if self._btle is None: + raise exc.BleakError("Cannot enable notifications while disconnected") try: - await self._btle.start_notify(self.uuid_notify, self._delegation.handleNotification) + notify_kwargs = {} + if sys.platform.startswith("linux"): + # Tion can send a notification immediately after the CCCD is + # written. BlueZ AcquireNotify can miss that first packet; + # StartNotify keeps the subscription ordering deterministic. + notify_kwargs["bluez"] = {"use_start_notify": True} + await self._btle.start_notify( + self.uuid_notify, + self._delegation.handleNotification, + **notify_kwargs, + ) except exc.BleakError as e: _LOGGER.warning("Got exception %s while enabling notifications!" % str(e)) raise e @@ -482,6 +526,8 @@ async def pair(self): await self._connect(need_notifications=False) _LOGGER.debug("Connected. BT pairing ...") try: + if self._btle is None: + raise exc.BleakError("Cannot pair while disconnected") await self._btle.pair() # device-specific pairing _LOGGER.debug("Device-specific pairing ...") @@ -579,10 +625,10 @@ def update_btle_device(self, new_device: str | BLEDevice): @final def set_new_btle_device(self): if self._next_btle_device is not None: - try: - _LOGGER.debug(f"Updating _btle instance from {self._btle} to {self._next_btle_device}") - except AttributeError: - pass - - self._btle = BleakClient(self._next_btle_device) + _LOGGER.debug( + "Updating BLE device from %s to %s", + self._ble_device, + self._next_btle_device, + ) + self._ble_device = self._next_btle_device self._next_btle_device = None