Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: CI tests
on:
push:
branches: [ master ]
branches: [ master, HA2026_fix ]
pull_request:
branches: [ master ]
types: [opened, synchronize]
Expand All @@ -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 }}

Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
```
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
bleak>=0.14.3
bleak>=2.1.1
15 changes: 10 additions & 5 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
96 changes: 96 additions & 0 deletions tests/unit/test_tion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
)
13 changes: 8 additions & 5 deletions tion_btle/light_family.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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()
Expand Down Expand Up @@ -181,4 +185,3 @@ def REQUEST_DEVICE_INFO(self) -> list:
def _packages(self) -> list:
"""Packages for tests"""
raise NotImplementedError()

12 changes: 8 additions & 4 deletions tion_btle/lite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand Down
12 changes: 8 additions & 4 deletions tion_btle/s3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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
Expand Down
12 changes: 8 additions & 4 deletions tion_btle/s4.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,23 @@
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)
_LOGGER = logging.getLogger(__name__)


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']

Expand Down
Loading