├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── release.yml │ └── tests.yml ├── .gitignore ├── LICENSE ├── README.md ├── pair.py ├── pytest.ini ├── requirements.txt ├── requirements_test.txt ├── setup.py ├── tests ├── __init__.py ├── functional │ ├── __init__.py │ ├── test_lite.py │ └── test_s4.py └── unit │ ├── __init__.py │ ├── test_decode.py │ ├── test_lite.py │ ├── test_tion.py │ └── tion.py └── tion_btle ├── __init__.py ├── light_family.py ├── lite.py ├── s3.py ├── s4.py └── tion.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Если вы столкнулись с проблемой -- расскажите о ней 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Версия модуля**: 11 | **Модель бризера**: 12 | **Версия прошивки (если известна)**: 13 | ## Описание проблемы 14 | Опишите возникшую проблему простыми словами 15 | 16 | ## Порядок действий, которые приводят к проявлению проблемы 17 | Опишите порядок действий, которые приводят к проявлению проблемы 18 | 19 | ## Логи 20 | ``` 21 | Вставьте сюда debug-log, в момент проявления проблемы 22 | ``` 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create release 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | changes: 11 | name: "Create changelog and tag" 12 | runs-on: ubuntu-latest 13 | outputs: 14 | skipped: ${{ steps.changelog.outputs.skipped }} 15 | clean_changelog: ${{ steps.changelog.outputs.clean_changelog }} 16 | tag: ${{ steps.changelog.outputs.tag }} 17 | 18 | steps: 19 | - name: checkout 20 | uses: actions/checkout@v2 21 | id: checkout 22 | 23 | - name: Conventional Changelog Action 24 | id: changelog 25 | uses: TriPSs/conventional-changelog-action@v3 26 | with: 27 | github-token: ${{ secrets.github_token }} 28 | output-file: "false" 29 | skip-version-file: "true" 30 | skip-commit: "true" 31 | 32 | release: 33 | name: "Create release" 34 | needs: changes 35 | if: ${{ needs.changes.outputs.skipped == 'false' }} 36 | runs-on: ubuntu-latest 37 | 38 | steps: 39 | - name: Create Release 40 | id: release 41 | uses: actions/create-release@v1 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.github_token }} 44 | with: 45 | tag_name: ${{ needs.changes.outputs.tag }} 46 | release_name: ${{ needs.changes.outputs.tag }} 47 | body: ${{ needs.changes.outputs.clean_changelog }} 48 | 49 | publish: 50 | name: "Publish at PyPi" 51 | needs: changes 52 | if: ${{ needs.changes.outputs.skipped == 'false' }} 53 | runs-on: ubuntu-latest 54 | 55 | steps: 56 | - name: checkout 57 | uses: actions/checkout@v2 58 | id: checkout 59 | with: 60 | ref: ${{ needs.changes.outputs.tag }} 61 | 62 | - name: Prepare python for publish 63 | uses: actions/setup-python@v4 64 | with: 65 | python-version: '3.x' 66 | 67 | - name: Install dependencies 68 | run: | 69 | sudo apt-get install bluez 70 | python -m pip install --upgrade pip 71 | pip install setuptools wheel twine 72 | pip install -r requirements.txt 73 | 74 | 75 | - name: Set version 76 | env: 77 | VERSION: ${{ needs.changes.outputs.tag }} 78 | run: sed --in-place -e "s/%%%VERSION%%%/${VERSION##v}/" setup.py 79 | 80 | - name: Build 81 | run: python setup.py sdist bdist_wheel 82 | 83 | - name: Publish 84 | env: 85 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 86 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 87 | run: twine upload dist/* 88 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: CI tests 2 | on: 3 | push: 4 | branches: [ master ] 5 | pull_request: 6 | branches: [ master ] 7 | types: [opened, synchronize] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | # This workflow contains a single job called "build" 12 | tests: 13 | name: Run ${{ matrix.type }} tests for python ${{matrix.python}} 14 | # The type of runner that the job will run on 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python: ['3.9', '3.10'] 19 | type: [unit] 20 | 21 | # Steps represent a sequence of tasks that will be executed as part of the job 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v2.3.4 25 | 26 | - name: Prepare python ${{ matrix.python }} 27 | uses: actions/setup-python@v4 28 | with: 29 | python-version: ${{ matrix.python }} 30 | 31 | - name: Install bluez 32 | run: sudo apt-get install bluez 33 | 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install --upgrade pip 37 | pip install setuptools wheel twine 38 | 39 | - name: Install requirements 40 | run: pip install -r requirements_test.txt 41 | 42 | - name: Run ${{ matrix.type }} tests 43 | run: pytest tests/${{ matrix.type }} 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![CI tests](https://github.com/TionAPI/tion_python/workflows/CI%20tests/badge.svg?branch=master&event=push) 2 | 3 | # About 4 | This module will allow you to control your Tion S3 or Tion Lite breezer via bluetooth. 5 | 6 | If you want to use MagicAir API please follow https://github.com/airens/tion 7 | # Installation 8 | ```bash 9 | pip3 install tion-btle 10 | ``` 11 | # Usage 12 | Use class according to the breezer type. For example for Tion S3 you should use 13 | ```python 14 | from tion_btle import S3 as Breezer 15 | ``` 16 | # Documentation 17 | ## Few notes about asyncio 18 | `get`, `set`, `pair`, `connect` and `disconnect` methods are async. Use it in async loop: 19 | ## init 20 | You must provide device's MAC-address to the constructor 21 | ```python 22 | from tion_btle import S3 as Breezer 23 | mac: str=str("XX:XX:XX:XX:XX:XX") 24 | device = Breezer(mac) 25 | ``` 26 | ## get 27 | Use `get()` function to get current state of the breezer. 28 | It will return json with all available attributes. 29 | ```python 30 | print(await device.get()) 31 | ``` 32 | Result will depend on the breezer model 33 | #### All models 34 | * state -- current breezer state (on/off) 35 | * heater -- current heater status (on/off) 36 | * heating -- is breezer heating right now (on/off). For example, if the output temerature is 25 and target temperature 21, then heater may be ON, but heating will be OFF 37 | * sound -- current sound mode (on/off) 38 | * mode -- current air source (depend on model: outside/recirculation for all plus mixed for S3) 39 | * out_temp -- air temperature at the outlet of the device 40 | * in_temp -- air temperature at the inlet to the device 41 | * heater_temp -- target temperature for device 42 | * fan_speed -- current fan speed (1..6) 43 | * filter_remain 44 | * time -- time when parameters were taken. May be different from the current time. Depends on breezer time for S3 45 | * request_error_code -- error code for the request (0 if all goes well) 46 | * code -- response code (200 if all goes well) 47 | * model -- breezer model (S3/Lite) 48 | #### S3 49 | This parameters are available only for S3: 50 | * productivity -- current flow in m^3/h 51 | * fw_version -- breezer firmware version 52 | * timer -- timer state (on/off) 53 | #### Lite 54 | This parameters are available only for Lite: 55 | * device_work_time 56 | * electronic_work_time 57 | * electronic_temp 58 | * co2_auto_control -- co2 auto control status (on/off). When breezer is used with MagicAir 59 | * filter_change_required -- is filter change required (on/off) 60 | * light -- light state (on/off) 61 | ## set 62 | Use `set({parameter1: value, parameter2: value, ...})` to set breezer parameters that may be changed. It depends on the breezer model. 63 | ```python 64 | await device.set({ 65 | 'fan_speed': 4, 66 | 'heater_temp': 21, 67 | 'heater': 'on' 68 | }) 69 | ``` 70 | ### All models 71 | * state -- current breezer state (on/off) 72 | * heater -- current heater status (on/off) 73 | * sound -- current sound mode (on/off) 74 | * mode -- current air source (depend on model: outside/recirculation for all plus mixed for S3) 75 | * heater_temp -- target temperature for the device 76 | * fan_speed -- current fan speed (1..6) 77 | ### Lite 78 | This parameters may be set only for Tion Lite 79 | * light -- light state (on/off) 80 | * co2_auto_control -- co2 auto control status (on/off). When breezer is used with MagicAir 81 | 82 | ## pair 83 | To pair device turn breezer to pairing mode and call 84 | ```python 85 | await device.pair() 86 | ``` 87 | -------------------------------------------------------------------------------- /pair.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import sys 3 | from tion_btle.s3 import S3 as s3device 4 | 5 | s3 = s3device(sys.argv[1]) 6 | s3.pair() 7 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -v 3 | asyncio_mode = auto -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | bleak>=0.14.3 2 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | pytest 3 | pytest-asyncio -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | from setuptools import setup, find_packages 3 | import os 4 | 5 | 6 | here = os.path.abspath(os.path.dirname(__file__)) 7 | 8 | req = [] 9 | with open("requirements.txt") as f: 10 | req.append(f.read()) 11 | 12 | setup( 13 | name='tion_btle', 14 | version='%%%VERSION%%%', 15 | long_description="Module for working with Tion breezers", 16 | url='https://github.com/TionAPI/tion_python/tree/dev', 17 | install_requires=[req], 18 | description='Python module for interacting with Tion breezers', 19 | packages=find_packages(), 20 | ) 21 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TionAPI/tion_python/7c077081a9b63e4583a85801b8b4c7cd1019a25b/tests/__init__.py -------------------------------------------------------------------------------- /tests/functional/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TionAPI/tion_python/7c077081a9b63e4583a85801b8b4c7cd1019a25b/tests/functional/__init__.py -------------------------------------------------------------------------------- /tests/functional/test_lite.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from tion_btle.lite import Lite 4 | 5 | logging.basicConfig(level=logging.DEBUG) 6 | _LOGGER = logging.getLogger(__name__) 7 | _LOGGER.setLevel("DEBUG") 8 | try: 9 | mac = sys.argv[1] 10 | if mac == 'discover': 11 | _LOGGER.debug("%s", sys.argv) 12 | mac = 'dummy' 13 | except IndexError: 14 | mac = "dummy" 15 | 16 | device = Lite(mac) 17 | 18 | result = device.get() 19 | print("crc is: " + bytes(device._crc).hex()) 20 | 21 | print("header._package_size = %s" % device._package_size) 22 | print("header_commad_type = %s" % bytes(device._command_type).hex()) 23 | 24 | _LOGGER.debug("Result is %s " % result) 25 | 26 | _LOGGER.info("Initial state: device is %s, light is %s, sound is %s, heater is %s, fan_speed is %d, heater_temp is %d", 27 | device.state, 28 | device.light, 29 | device.sound, 30 | device.heater, 31 | device.fan_speed, 32 | device.heater_temp 33 | ) 34 | 35 | 36 | -------------------------------------------------------------------------------- /tests/functional/test_s4.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | import time 4 | from tion_btle.s4 import S4 5 | 6 | 7 | def read_state(tion: S4): 8 | result = tion.get() 9 | 10 | _LOGGER.debug("Result is %s " % result) 11 | 12 | _LOGGER.info("Initial state: device is %s, light is %s, sound is %s, heater is %s, fan_speed is %d, target_temp is %d", 13 | tion.state, 14 | tion.light, 15 | tion.sound, 16 | tion.heater, 17 | tion.fan_speed, 18 | tion.heater_temp) 19 | 20 | 21 | def pause(interval: int = 10): 22 | _LOGGER.info("Sleeping %s seconds...", interval) 23 | time.sleep(interval) 24 | 25 | 26 | logging.basicConfig(level=logging.DEBUG) 27 | _LOGGER = logging.getLogger(__name__) 28 | _LOGGER.setLevel("DEBUG") 29 | try: 30 | mac = sys.argv[1] 31 | if mac == 'discover': 32 | _LOGGER.debug("%s", sys.argv) 33 | mac = 'dummy' 34 | except IndexError: 35 | mac = "dummy" 36 | 37 | device = S4(mac) 38 | 39 | sleep_step: int = 10 40 | original_state = device.state 41 | original_fan_speed = device.fan_speed 42 | 43 | tests = { 44 | 'state': { 45 | 'on': ['off', 'on'], 46 | 'off': ['on'], 47 | }, 48 | 'heater': { 49 | 'on': ['off', 'on'], 50 | 'off': ['on', 'off'], 51 | }, 52 | 'mode': { 53 | 'recirculation': ['outside', 'recirculation'], 54 | 'outside': ['recirculation', 'recirculation'], 55 | }, 56 | 'fan_speed': {} 57 | } 58 | 59 | read_state(device) 60 | pause(sleep_step) 61 | 62 | for test in tests.keys(): 63 | _LOGGER.info("Testing %s", test) 64 | states = tests[test][getattr(device, test)] if test != 'fan_speed' else [2, 5] 65 | for state in states: 66 | _LOGGER.info("Going to set %s to %s", test, state) 67 | device.set({test: state}) 68 | pause(sleep_step) 69 | read_state(device) 70 | pause(sleep_step) 71 | 72 | # restoring original state 73 | if original_state == 'off': 74 | device.set({'state': original_state}) 75 | elif original_fan_speed != 5: 76 | device.set({'fan_speed': original_fan_speed}) 77 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TionAPI/tion_python/7c077081a9b63e4583a85801b8b4c7cd1019a25b/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_decode.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from typing import List, Type, Union 3 | 4 | import tion_btle 5 | 6 | 7 | scenarios: List[dict[str, Union[Type[tion_btle.Tion], bytearray, dict]]] = [ 8 | { 9 | "instance": tion_btle.s3.TionS3, 10 | "data": [0xb3, 0x10, 0x24, 0x14, 0x03, 0x00, 0x15, 0x14, 0x14, 0x8f, 0x00, 0x0c, 0x0a, 0x00, 0x4b, 0x0a, 0x00, 11 | 0x33, 0x00, 0x5a], 12 | "results": { 13 | 'state': 'on', 14 | 'heater': 'on', 15 | 'sound': 'off', 16 | 'mode': 'outside', 17 | 'out_temp': 20, 18 | 'in_temp': 20, 19 | 'target_temp': 20, 20 | 'fan_speed': 4, 21 | '_filter_remain': 143, 22 | 'model': 'S3', 23 | } 24 | }, 25 | { 26 | "instance": tion_btle.lite.TionLite, 27 | "data": [0xcf, 0xd8, 0x02, 0x0f, 0x04, 0x09, 0x0f, 0x1a, 0x80, 0x8e, 0x05, 0x00, 0xe9, 0x8b, 0x05, 0x00, 0x17, 0xc2, 28 | 0xe7, 0x00, 0x26, 0x1b, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 29 | 0x00, 0x04, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x14, 0x19, 0x02, 0x04, 0x06, 30 | 0x06, 0x18, 0x00], 31 | "results": { 32 | 'state': 'on', 33 | 'heater': 'on', 34 | 'sound': 'on', 35 | 'mode': 'outside', 36 | 'out_temp': 15, 37 | 'in_temp': 9, 38 | 'target_temp': 15, 39 | 'fan_speed': 4, 40 | '_filter_remain': 175.7928587962963, 41 | 'model': 'Lite', 42 | '_device_work_time': 18.284884259259258, 43 | '_electronic_work_time': 4.214814814814815, 44 | '_electronic_temp': 26, 45 | '_co2_auto_control': 0, 46 | '_filter_change_required': 0, 47 | 'light': 'on', 48 | } 49 | }, 50 | { 51 | "instance": tion_btle.TionS4, 52 | "data": [0x2f, 0x51, 0x00, 0x19, 0x04, 0x0e, 0x10, 0x1b, 0x26, 0x3b, 0x6e, 0x07, 0x00, 0xfa, 0x4e, 0x07, 0x00, 53 | 0x06, 0xff, 0xe5, 0x00, 0xa6, 0xe9, 0x22, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x00], 54 | "results": { 55 | 'state': 'on', 56 | 'heater': 'on', 57 | 'sound': 'on', 58 | 'mode': 'outside', 59 | 'out_temp': 16, 60 | 'in_temp': 14, 61 | 'target_temp': 25, 62 | 'fan_speed': 4, 63 | '_filter_remain': 174.45636574074075, 64 | 'model': 'S4', 65 | } 66 | } 67 | ] 68 | 69 | 70 | def pytest_generate_tests(metafunc): 71 | global scenarios 72 | tests = [] 73 | 74 | for scenario in scenarios: 75 | tion_type: Type[tion_btle.Tion] = scenario["instance"] 76 | tion: tion_btle.Tion = tion_type(mac="") 77 | tion._decode_response(response=scenario["data"]) 78 | 79 | for k in scenario["results"].keys(): 80 | v = scenario["results"][k] 81 | _id = f"{tion.__class__.__name__}-{k}" 82 | tests.append(pytest.param(_id, getattr(tion, k), v, id=_id)) 83 | 84 | metafunc.parametrize("parameter,decoded_value,expected_value", tests) 85 | 86 | 87 | def test_param(parameter, decoded_value, expected_value): 88 | assert decoded_value == expected_value 89 | -------------------------------------------------------------------------------- /tests/unit/test_lite.py: -------------------------------------------------------------------------------- 1 | from random import randrange 2 | import pytest 3 | 4 | from tion_btle.light_family import TionLiteFamily 5 | 6 | 7 | def generator(_len: int) -> bytearray: 8 | """Generate random bytes bytearray wit len size.""" 9 | result = [] 10 | for i in range(_len): 11 | result.append(randrange(0xFF)) 12 | return bytearray(result) 13 | 14 | 15 | @pytest.mark.parametrize( 16 | "target_length", 17 | [0, 1, 10, 30, 90] 18 | ) 19 | def test_generator(target_length): 20 | assert len(generator(target_length)) == target_length 21 | 22 | 23 | @pytest.mark.parametrize( 24 | "command", 25 | [ 26 | pytest.param(generator(10), id="len=10"), 27 | pytest.param(generator(20), id="len=20"), 28 | pytest.param(generator(30), id="len=30"), 29 | pytest.param(generator(40), id="len=40"), 30 | pytest.param(generator(50), id="len=50"), 31 | ] 32 | ) 33 | def test_split_command(command: bytearray): 34 | tion = TionLiteFamily(mac="") 35 | splitted = tion.split_command(request=command.copy()) 36 | joined = bytearray() 37 | 38 | for i in range(len(splitted)): 39 | assert len(splitted[i]) <= 20 40 | if i == 0: 41 | if len(command) <= 20: 42 | assert splitted[i][0] == TionLiteFamily.SINGLE_PACKET_ID 43 | else: 44 | assert splitted[i][0] == TionLiteFamily.FIRST_PACKET_ID 45 | elif i == len(splitted)-1: 46 | assert splitted[i][0] == TionLiteFamily.END_PACKET_ID 47 | else: 48 | assert splitted[i][0] == TionLiteFamily.MIDDLE_PACKET_ID 49 | joined += splitted[i][1:] 50 | 51 | assert command[1:] == joined 52 | 53 | 54 | -------------------------------------------------------------------------------- /tests/unit/test_tion.py: -------------------------------------------------------------------------------- 1 | import time 2 | import pytest 3 | import unittest.mock as mock 4 | 5 | from bleak import exc 6 | 7 | import tion_btle.tion 8 | from tion_btle.tion import Tion 9 | from tion_btle.lite import TionLiteFamily 10 | from tion_btle.lite import TionLite 11 | from tion_btle.s3 import TionS3 12 | from tion_btle.s4 import TionS4 13 | from tion_btle.tion import retry, MaxTriesExceededError 14 | 15 | 16 | @pytest.mark.asyncio 17 | @pytest.mark.parametrize( 18 | "retries, repeats, succeed_run, t_delay", 19 | [ 20 | pytest.param(0, 1, 0, 0, id="Succeed after first attempt with no retry"), 21 | pytest.param(1, 1, 0, 0, id="Succeed after first attempt with retry"), 22 | pytest.param(5, 4, 3, 0, id="Succeed after first 3rd attempt with 5 retry"), 23 | pytest.param(1, 2, 3, 0, id="Fail after one retry"), 24 | pytest.param(2, 2, 1, 2, id="Delay between retries"), 25 | ] 26 | ) 27 | async def test_retry(retries: int, repeats: int, succeed_run: int, t_delay: int): 28 | class TestRetry: 29 | count = 0 30 | 31 | @retry(retries=retries, delay=t_delay) 32 | def a(self, _succeed_run: int = 0): 33 | if self.count <= _succeed_run: 34 | self.count += 1 35 | if self.count - 1 == _succeed_run: 36 | return "expected_result" 37 | 38 | raise exc.BleakError 39 | 40 | i = TestRetry() 41 | start = time.time() 42 | 43 | if succeed_run < repeats: 44 | assert await i.a(_succeed_run=succeed_run) == "expected_result" 45 | else: 46 | with pytest.raises(MaxTriesExceededError) as c: 47 | await i.a(_succeed_run=succeed_run) 48 | 49 | end = time.time() 50 | 51 | assert i.count == repeats 52 | assert end - start >= t_delay 53 | 54 | 55 | class TestLogLevels: 56 | count = 0 57 | 58 | def setUp(self): 59 | self.count = 0 60 | tion_btle.tion._LOGGER.debug = mock.MagicMock(name='method') 61 | tion_btle.tion._LOGGER.info = mock.MagicMock(name='method') 62 | tion_btle.tion._LOGGER.warning = mock.MagicMock(name='method') 63 | tion_btle.tion._LOGGER.critical = mock.MagicMock(name='method') 64 | 65 | @pytest.mark.asyncio 66 | async def test_debug_log_level(self): 67 | @retry(retries=0) 68 | async def debug(): 69 | pass 70 | 71 | with mock.patch('tion_btle.tion._LOGGER') as log_mock: 72 | await debug() 73 | log_mock.debug.assert_called() 74 | log_mock.info.assert_not_called() 75 | log_mock.warning.assert_not_called() 76 | log_mock.critical.assert_not_called() 77 | 78 | @pytest.mark.asyncio 79 | async def test_warning_log_level(self): 80 | """Make sure that we have warnings for exception, but have no critical if all goes well finally""" 81 | @retry(retries=1) 82 | async def warning(): 83 | if self.count == 0: 84 | self.count += 1 85 | raise exc.BleakError 86 | else: 87 | pass 88 | 89 | with mock.patch('tion_btle.tion._LOGGER') as log_mock: 90 | await warning() 91 | log_mock.warning.assert_called() 92 | log_mock.critical.assert_not_called() 93 | 94 | @pytest.mark.asyncio 95 | async def test_critical_log_level(self): 96 | """Make sure that we have message at critical level if all goes bad""" 97 | @retry(retries=0) 98 | async def critical(): 99 | raise exc.BleakError 100 | 101 | with mock.patch('tion_btle.tion._LOGGER.critical') as log_mock: 102 | try: 103 | await critical() 104 | except MaxTriesExceededError: 105 | pass 106 | log_mock.assert_called() 107 | 108 | 109 | @pytest.mark.parametrize( 110 | "raw_temperature, result", 111 | [ 112 | [0x09, 9], 113 | [0xFF, -1] 114 | ] 115 | ) 116 | def test_decode_temperature(raw_temperature, result): 117 | assert Tion.decode_temperature(raw_temperature) == result 118 | 119 | 120 | @pytest.mark.parametrize( 121 | "instance", 122 | [Tion, TionLiteFamily, TionLite, TionS3, TionS4] 123 | ) 124 | def test_mac(instance): 125 | target = 'foo' 126 | t_tion = instance(target) 127 | assert t_tion.mac == target 128 | -------------------------------------------------------------------------------- /tests/unit/tion.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from tion_btle.tion import Tion 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "heater, heater_temp, in_temp, out_temp, result", 8 | [ 9 | ["on", 20, -2, 21, "on"], 10 | ["on", 20, 15, 21, "on"], 11 | ["on", 16, 15, 21, "off"], 12 | ["off", 20, 15, 21, "off"], 13 | ]) 14 | def test__detect_heating_state(heater, in_temp, out_temp, heater_temp, result): 15 | """Test heating detection""" 16 | 17 | tion = Tion(mac="") 18 | tion.heater = heater 19 | tion._in_temp = in_temp 20 | tion._out_temp = out_temp 21 | tion._heater_temp = heater_temp 22 | 23 | assert tion.heating == result 24 | -------------------------------------------------------------------------------- /tion_btle/__init__.py: -------------------------------------------------------------------------------- 1 | from .s3 import TionS3 2 | from .lite import TionLite 3 | from .s4 import TionS4 4 | from .tion import Tion 5 | -------------------------------------------------------------------------------- /tion_btle/light_family.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import logging 5 | from random import randrange 6 | from typing import final, List 7 | 8 | from bleak.backends.device import BLEDevice 9 | 10 | if __package__ == "": 11 | from tion_btle.tion import Tion 12 | else: 13 | from .tion import Tion 14 | 15 | logging.basicConfig(level=logging.DEBUG) 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class TionLiteFamily(Tion): 20 | uuid: str = "98f00001-3788-83ea-453e-f52244709ddb" 21 | uuid_write: str = "98f00002-3788-83ea-453e-f52244709ddb" 22 | uuid_notify: str = "98f00003-3788-83ea-453e-f52244709ddb" 23 | uuid_notify_descriptor: str = "00002902-0000-1000-8000-00805f9b34fb" 24 | 25 | write: None 26 | notify: None 27 | 28 | SINGLE_PACKET_ID = 0x80 29 | FIRST_PACKET_ID = 0x00 30 | MIDDLE_PACKET_ID = 0x40 31 | END_PACKET_ID = 0xc0 32 | MAGIC_NUMBER: int = 0x3a # 58 33 | 34 | def __init__(self, mac: str | BLEDevice): 35 | super().__init__(mac) 36 | self._data: bytearray = bytearray() 37 | self._crc: bytearray = bytearray() 38 | self._header: bytearray = bytearray() 39 | self._have_full_package: bool = False 40 | self._got_new_sequence: bool = False 41 | self.have_breezer_state: bool = False 42 | 43 | # states 44 | self._light: bool = False 45 | self._have_heater: bool = False 46 | 47 | @final 48 | @property 49 | def light(self) -> str: 50 | return self._decode_state(self._light) 51 | 52 | @final 53 | @light.setter 54 | def light(self, new_state: str): 55 | self._light = self._encode_state(new_state) 56 | 57 | @final 58 | @property 59 | def random(self) -> bytes: 60 | # return random hex number. 61 | return randrange(0xFF) 62 | 63 | @final 64 | @property 65 | def random4(self) -> list: 66 | # return 4 random hex. 67 | return [self.random, self.random, self.random, self.random] 68 | 69 | @final 70 | def _collect_message(self, package: bytearray) -> bool: 71 | self._have_full_package = False 72 | 73 | _LOGGER.debug("Got %s from tion", bytes(package).hex()) 74 | 75 | if package[0] == self.FIRST_PACKET_ID or package[0] == self.SINGLE_PACKET_ID: 76 | self._data = package 77 | self._have_full_package = True if package[0] == self.SINGLE_PACKET_ID else False 78 | self._got_new_sequence = True if package[0] == self.FIRST_PACKET_ID else False 79 | elif package[0] == self.MIDDLE_PACKET_ID: 80 | if not self._got_new_sequence: 81 | _LOGGER.critical("Got middle packet but waiting for a first!") 82 | else: 83 | package = list(package) 84 | package.pop(0) 85 | self._data += bytearray(package) 86 | elif package[0] == self.END_PACKET_ID: 87 | if not self._got_new_sequence: 88 | _LOGGER.critical("Got end packet but waiting for a first!") 89 | else: 90 | self._have_full_package = True 91 | self._got_new_sequence = False 92 | package = list(package) 93 | package.pop(0) 94 | self._data += bytearray(package) 95 | else: 96 | _LOGGER.error("Unknown package id %s", hex(package[0])) 97 | 98 | if self._have_full_package: 99 | self._header = self._data[:15] 100 | self._data = self._data[15:-2] 101 | self._crc = self._data[-2:] 102 | 103 | return self._have_full_package 104 | 105 | @final 106 | def split_command(self, request: bytearray) -> List[bytearray]: 107 | def chunks(lst, n): 108 | """Yield successive n-sized chunks from lst.""" 109 | for j in range(0, len(lst), n): 110 | yield lst[j:j + n] 111 | 112 | request.pop(0) 113 | 114 | if len(request) < 20: 115 | request.insert(0, self.SINGLE_PACKET_ID) 116 | return [request] 117 | 118 | result = list(chunks(request, 19)) 119 | 120 | for i in range(0, len(result)): 121 | if i == 0: # First packet 122 | result[i].insert(0, self.FIRST_PACKET_ID) 123 | elif i == len(result)-1: # Last packet 124 | result[i].insert(0, self.END_PACKET_ID) 125 | else: # Middle packets 126 | result[i].insert(0, self.MIDDLE_PACKET_ID) 127 | 128 | return result 129 | 130 | @final 131 | async def _send_request(self, request: bytearray): 132 | self.have_breezer_state = False 133 | 134 | for d in self.split_command(request): 135 | _LOGGER.debug("Doing write: request=%s", bytes(d).hex()) 136 | await self._try_write(d) 137 | 138 | async def _pair(self): 139 | """Lite family breezers is not require special pairing procedure""" 140 | return 141 | 142 | @final 143 | @property 144 | def CRC(self) -> list: 145 | return [0xbb, 0xaa] 146 | 147 | @abc.abstractmethod 148 | def _encode_request(self, request: dict) -> bytearray: 149 | """ 150 | encode requested parameters 151 | :param request: dict with parameters that we should set 152 | :return: bytearray of encoded request. result[0] will be overwritten in self._send_request() 153 | """ 154 | raise NotImplementedError() 155 | 156 | @abc.abstractmethod 157 | def _decode_response(self, response: bytearray): 158 | raise NotImplementedError() 159 | 160 | @abc.abstractmethod 161 | def _generate_model_specific_json(self) -> dict: 162 | raise NotImplementedError() 163 | 164 | @property 165 | @abc.abstractmethod 166 | def REQUEST_PARAMS(self) -> list: 167 | raise NotImplementedError() 168 | 169 | @property 170 | @abc.abstractmethod 171 | def SET_PARAMS(self) -> list: 172 | raise NotImplementedError() 173 | 174 | @property 175 | @abc.abstractmethod 176 | def REQUEST_DEVICE_INFO(self) -> list: 177 | raise NotImplementedError() 178 | 179 | @property 180 | @abc.abstractmethod 181 | def _packages(self) -> list: 182 | """Packages for tests""" 183 | raise NotImplementedError() 184 | 185 | -------------------------------------------------------------------------------- /tion_btle/lite.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from bleak.backends.device import BLEDevice 6 | 7 | if __package__ == "": 8 | from tion_btle.tion import TionException 9 | from tion_btle.light_family import TionLiteFamily 10 | else: 11 | from .tion import TionException 12 | from .light_family import TionLiteFamily 13 | 14 | logging.basicConfig(level=logging.DEBUG) 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | class TionLite(TionLiteFamily): 19 | 20 | def __init__(self, mac: str | BLEDevice): 21 | super().__init__(mac) 22 | self._package_size: bytearray = bytearray() 23 | self._command_type: bytearray = bytearray() 24 | self._request_id: bytearray = bytearray() 25 | self._sent_request_id: bytearray = bytearray() 26 | 27 | if mac == "dummy": 28 | _LOGGER.info("Dummy mode!") 29 | self._package_id: int = 0 30 | # states 31 | 32 | self._filter_change_required: bool = False 33 | self._co2_auto_control: bool = False 34 | self._electronic_temp: int = 0 35 | self._electronic_work_time: float = 0 36 | self._device_work_time: float = 0 37 | self._error_code: int = 0 38 | 39 | @property 40 | def REQUEST_PARAMS(self) -> list: 41 | return [0x32, 0x12] 42 | 43 | @property 44 | def SET_PARAMS(self) -> list: 45 | return [0x30, 0x12] 46 | 47 | @property 48 | def REQUEST_DEVICE_INFO(self) -> list: 49 | return [0x09, TionLiteFamily.MIDDLE_PACKET_ID] 50 | 51 | def _decode_header(self, header: bytearray): 52 | _LOGGER.debug("Header is %s", bytes(header).hex()) 53 | self._package_size = int.from_bytes(header[1:2], byteorder='big', signed=False) 54 | if header[3] != self.MAGIC_NUMBER: 55 | _LOGGER.error("Got wrong magic number at position 3") 56 | raise Exception("wrong magic number") 57 | self._command_type = reversed(header[5:6]) 58 | self._request_id = header[7:10] # must match self._sent_request_id 59 | self._command_number = header[11:14] 60 | 61 | @property 62 | def command_getStatus(self) -> bytearray: 63 | def generate_request_id() -> bytearray: 64 | self._sent_request_id = bytearray([0x0d, 0xd7, 0x1f, 0x8f]) 65 | return self._sent_request_id 66 | 67 | generate_request_id() 68 | packet_size = 0x10 # 17 bytes 69 | return bytearray( 70 | [self.SINGLE_PACKET_ID, packet_size, 0x00, self.MAGIC_NUMBER, 0x02] + self.REQUEST_PARAMS + list( 71 | self._sent_request_id) + [0x48, 0xd3, 0xc3, 0x1a] + self.CRC) 72 | 73 | def _decode_response(self, response: bytearray): 74 | _LOGGER.debug("Data is %s", bytes(response).hex()) 75 | try: 76 | self._state = response[0] & 1 77 | self._sound = response[0] >> 1 & 1 78 | self._light = response[0] >> 2 & 1 79 | self._filter_change_required = response[0] >> 4 & 1 80 | self._co2_auto_control = response[0] >> 5 & 1 81 | self._heater = response[0] >> 6 & 1 82 | self._have_heater = response[0] >> 7 & 1 83 | 84 | self._mode = response[2] 85 | self._heater_temp = response[3] 86 | self._fan_speed = response[4] 87 | self._in_temp = self.decode_temperature(response[5]) 88 | self._out_temp = self.decode_temperature(response[6]) 89 | self._electronic_temp = response[7] 90 | self._electronic_work_time = int.from_bytes(response[8:11], byteorder='little', signed=False) / 86400 # days 91 | self._filter_remain = int.from_bytes(response[16:20], byteorder='little', signed=False) / 86400 # days 92 | self._device_work_time = int.from_bytes(response[20:24], byteorder='little', signed=False) / 86400 # days 93 | self._error_code = response[28] 94 | 95 | # self._preset_temp = data[48:50] 96 | # self._preset_fan = data[51:53] 97 | # self._max_fan = data[54] 98 | # self._heater_percent = data[55] 99 | except IndexError as e: 100 | raise TionException( 101 | "Lite _decode_response", "Got bad response from Tion '%s': %s while parsing" % (response, str(e)) 102 | ) 103 | 104 | def _generate_model_specific_json(self) -> dict: 105 | return { 106 | "code": 200, 107 | "device_work_time": self._device_work_time, 108 | "electronic_work_time": self._electronic_work_time, 109 | "electronic_temp": self._electronic_temp, 110 | "co2_auto_control": str(self._co2_auto_control), 111 | "filter_change_required": str(self._filter_change_required), 112 | "light": self.light, 113 | } 114 | 115 | @property 116 | def __presets(self) -> list: 117 | return [0x0a, 0x14, 0x19, 0x02, 0x04, 0x06] 118 | 119 | def _encode_request(self, request: dict) -> bytearray: 120 | def encode_state(): 121 | result = \ 122 | self._encode_state(request["state"]) | \ 123 | (self._encode_state(request["sound"]) << 1) | \ 124 | (self._encode_state(request["light"]) << 2) | \ 125 | (self._encode_state(request["heater"]) << 4) 126 | return result 127 | 128 | sb = 0x00 # ?? 129 | tb = 0x02 if (self.heater_temp > 0 or self.fan_speed > 0) else 0x01 130 | lb = [0x60, 0x00] if sb == 0 else [0x00, 0x00] 131 | 132 | return bytearray( 133 | [0x00, 0x1e, 0x00, self.MAGIC_NUMBER, self.random] + 134 | self.SET_PARAMS + self.random4 + self.random4 + 135 | [encode_state(), sb, tb, int(request["heater_temp"]), int(request["fan_speed"])] + 136 | self.__presets + lb + [0x00] + self.CRC 137 | ) 138 | 139 | @property 140 | def _packages(self) -> list: 141 | return [ 142 | bytearray([0x00, 0x49, 0x00, 0x3a, 0x4e, 0x31, 0x12, 0x0d, 0xd7, 0x1f, 0x8f, 0xbf, 0xc9, 0x40, 0x37, 0xcf, 0xd8, 0x02, 0x0f, 0x04]), 143 | bytearray([0x40, 0x09, 0x0f, 0x1a, 0x80, 0x8e, 0x05, 0x00, 0xe9, 0x8b, 0x05, 0x00, 0x17, 0xc2, 0xe7, 0x00, 0x26, 0x1b, 0x18, 0x00]), 144 | bytearray([0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x04, 0x02, 0x00, 0x00, 0x00, 0x00]), 145 | bytearray([0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x14, 0x19, 0x02, 0x04, 0x06, 0x06, 0x18, 0x00, 0xb5, 0xad]) 146 | ] 147 | -------------------------------------------------------------------------------- /tion_btle/s3.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from bleak.backends.device import BLEDevice 6 | 7 | if __package__ == "": 8 | from tion_btle.tion import Tion, TionException 9 | else: 10 | from .tion import Tion, TionException 11 | 12 | logging.basicConfig(level=logging.DEBUG) 13 | _LOGGER = logging.getLogger(__name__) 14 | 15 | 16 | class TionS3(Tion): 17 | uuid = "6e400001-b5a3-f393-e0a9-e50e24dcca9e" 18 | uuid_write = "6e400002-b5a3-f393-e0a9-e50e24dcca9e" 19 | uuid_notify = "6e400003-b5a3-f393-e0a9-e50e24dcca9e" 20 | write = None 21 | notify = None 22 | 23 | _btle = None 24 | 25 | command_prefix = 61 26 | command_suffix = 90 27 | 28 | command_PAIR = 5 29 | command_REQUEST_PARAMS = 1 30 | command_SET_PARAMS = 2 31 | 32 | def __init__(self, mac: str | BLEDevice): 33 | super().__init__(mac) 34 | 35 | # S3-specific properties 36 | self._timer: bool = False 37 | self._time: str = "unknown" 38 | self._productivity: int = 0 39 | self._fw_version: str = "unknown" 40 | 41 | @property 42 | def pair_command(self) -> bytearray: 43 | return self.create_command(self.command_PAIR) 44 | 45 | @property 46 | def command_getStatus(self) -> bytearray: 47 | return self.create_command(self.command_REQUEST_PARAMS) 48 | 49 | async def _pair(self): 50 | _LOGGER.debug("Sending pair command") 51 | await self._send_request(self.pair_command) 52 | _LOGGER.debug("Done!") 53 | 54 | def create_command(self, command: int) -> bytearray: 55 | command_special = 1 if command == self.command_PAIR else 0 56 | return bytearray([self.command_prefix, command, command_special, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 57 | self.command_suffix]) 58 | 59 | def _collect_message(self, package: bytearray) -> bool: 60 | self._data = package 61 | return True 62 | 63 | def _decode_response(self, response: bytearray): 64 | _LOGGER.debug("Data is %s", bytes(response).hex()) 65 | try: 66 | self._fan_speed = int(list("{:02x}".format(response[2]))[1]) 67 | self._mode = int(list("{:02x}".format(response[2]))[0]) 68 | self._heater = response[4] & 1 69 | self._state = response[4] >> 1 & 1 70 | self._heater_temp = response[3] 71 | self._sound = response[4] >> 3 & 1 72 | self._out_temp = self.decode_temperature(response[7]) 73 | self._in_temp = self.decode_temperature(response[8]) 74 | self._filter_remain = response[10] * 256 + response[9] 75 | self._error_code = response[13] 76 | 77 | self._timer = self._process_status(response[4] >> 2 & 1) 78 | self._time = "{}:{}".format(response[11], response[12]) 79 | self._productivity = response[14] 80 | self._fw_version = "{:02x}{:02x}".format(response[18], response[17]) 81 | 82 | except IndexError as e: 83 | raise TionException("s3 _decode_response", "Got bad response from Tion '%s': %s while parsing" % (response, str(e))) 84 | 85 | def _generate_model_specific_json(self) -> dict: 86 | return { 87 | "code": 200, 88 | "timer": self._timer, 89 | "time": self._time, 90 | "productivity": self._productivity, 91 | "fw_version": self._fw_version, 92 | } 93 | 94 | def _encode_request(self, request: dict) -> bytearray: 95 | new_settings = self.create_command(self.command_SET_PARAMS) 96 | new_settings[2] = int(request["fan_speed"]) 97 | new_settings[3] = int(request["heater_temp"]) 98 | new_settings[4] = self._encode_mode(request["mode"]) 99 | new_settings[5] = self._encode_status(request["heater"]) | (self._encode_status(request["state"]) << 1) | ( 100 | self._encode_status(request["sound"]) << 3) 101 | return new_settings 102 | 103 | async def _send_request(self, request: bytearray): 104 | await self._try_write(request) 105 | -------------------------------------------------------------------------------- /tion_btle/s4.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from bleak.backends.device import BLEDevice 6 | 7 | if __package__ == "": 8 | from tion_btle.tion import TionException 9 | from tion_btle.light_family import TionLiteFamily 10 | else: 11 | from .tion import TionException 12 | from .light_family import TionLiteFamily 13 | 14 | logging.basicConfig(level=logging.DEBUG) 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | class TionS4(TionLiteFamily): 19 | def __init__(self, mac: str | BLEDevice): 20 | super().__init__(mac) 21 | 22 | self.modes = ['outside', 'recirculation'] 23 | 24 | if mac == "dummy": 25 | _LOGGER.info("Dummy mode!") 26 | self._package_id: int = 0 27 | 28 | @property 29 | def REQUEST_DEVICE_INFO(self) -> list: 30 | return [50, 51] # 0x32 0x33 31 | 32 | @property 33 | def SET_PARAMS(self) -> list: 34 | return [48, 50] # 0x30 0x32 35 | 36 | @property 37 | def REQUEST_PARAMS(self) -> list: 38 | return [50, 50] # 0x32 0x32 39 | 40 | def _decode_response(self, response: bytearray): 41 | _LOGGER.debug("Data is %s", bytes(response).hex()) 42 | try: 43 | self._mode = response[2] 44 | self._heater_temp = response[3] 45 | self._fan_speed = response[4] 46 | self._in_temp = self.decode_temperature(response[5]) 47 | self._out_temp = self.decode_temperature(response[6]) 48 | self._filter_remain = int.from_bytes(response[17:20], byteorder='little', signed=False) / 86400 49 | self._state = response[0] & 1 50 | self._sound = response[0] >> 1 & 1 51 | self._light = response[0] >> 2 & 1 52 | self._heater = True if response[0] >> 4 & 1 == 0 else False 53 | except IndexError as e: 54 | raise TionException( 55 | "s4 _decode_response", 56 | f"Got bad response from Tion '{response}': {str(e)} while parsing" 57 | ) 58 | 59 | def _generate_model_specific_json(self) -> dict: 60 | return { 61 | "light": self.light 62 | } 63 | 64 | def _encode_request(self, request: dict) -> bytearray: 65 | def encode_state() -> int: 66 | """Encode different device states to single status int""" 67 | # power sound light heater true resetSettings resetErrorCounter resetFilterResource 68 | # 0 1 2 3 4 5 6 7 69 | return self._encode_state(request["state"]) | \ 70 | (self._encode_state(request["sound"]) << 1) | \ 71 | (self._encode_state(request["light"]) << 2) | \ 72 | ((not self._encode_state(request["heater"])) << 3) | \ 73 | (True << 4) 74 | try: 75 | sign = 181 76 | except KeyError: 77 | sign = 0 78 | 79 | return bytearray([0x00, 0x17, 0x00, self.MAGIC_NUMBER, self.random] + 80 | self.SET_PARAMS + self.random4 + self.random4 + 81 | [ 82 | encode_state(), 0x00, self._encode_mode(request["mode"]), int(request["heater_temp"]), 83 | int(request["fan_speed"]) 84 | ] + 85 | list(sign.to_bytes(2, byteorder='little')) + self.CRC 86 | ) 87 | 88 | @property 89 | def _packages(self) -> list: 90 | return [ 91 | # | | | 92 | bytearray([0x00, 0x2f, 0x00, 0x3a, 0x27, 0x31, 0x32, 0x72, 0x7b, 0x64, 0xd7, 0x31, 0xea, 0x58, 0x3a, 0x2f, 0x51, 0x00, 0x19, 0x04]), 93 | bytearray([0x40, 0x0e, 0x10, 0x1b, 0x26, 0x3b, 0x6e, 0x07, 0x00, 0xfa, 0x4e, 0x07, 0x00, 0x06, 0xff, 0xe5, 0x00, 0xa6, 0xe9, 0x22]), 94 | bytearray([0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x98, 0x5d]) 95 | ] 96 | 97 | @property 98 | def command_getStatus(self) -> bytearray: 99 | return bytearray([TionLiteFamily.SINGLE_PACKET_ID, 0x10, 0x00, self.MAGIC_NUMBER, 0xa1] + 100 | self.REQUEST_PARAMS + 101 | self.random4 + self.random4 + 102 | self.CRC 103 | ) 104 | -------------------------------------------------------------------------------- /tion_btle/tion.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import asyncio 5 | import inspect 6 | import logging 7 | from asyncio import Semaphore 8 | from typing import Callable, List, final 9 | from time import localtime, strftime 10 | 11 | from bleak import BleakClient 12 | from bleak import exc 13 | from bleak.backends.device import BLEDevice 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | 17 | 18 | class MaxTriesExceededError(Exception): 19 | pass 20 | 21 | 22 | def retry(retries: int = 2, delay: int = 0): 23 | def decor(f: Callable): 24 | async def wrapper(*args, **kwargs): 25 | last_info_exception = None 26 | last_warning_exception = None 27 | for i in range(retries+1): 28 | try: 29 | _LOGGER.debug("Trying %d/%d: %s(args=%s,kwargs=%s)", i, retries, f.__name__, args, kwargs) 30 | if inspect.iscoroutinefunction(f): 31 | return await f(*args, **kwargs) 32 | return f(*args, **kwargs) 33 | except (exc.BleakError, exc.BleakDBusError) as _e: 34 | next_message = "Will try again" if i < retries else "Will not try again" 35 | _LOGGER.warning("Got exception: %s. %s", str(_e), next_message) 36 | last_warning_exception = _e 37 | if delay > 0: 38 | await asyncio.sleep(delay) 39 | 40 | _LOGGER.critical("Retry limit (%d) exceeded for %s(%s, %s)", retries, f.__name__, args, kwargs) 41 | if _LOGGER.level > logging.INFO and last_info_exception is not None: 42 | _LOGGER.critical(f"Last exception was {last_info_exception}") 43 | elif _LOGGER.level > logging.WARNING and last_warning_exception is not None: 44 | _LOGGER.critical(f"Last exception was {last_warning_exception}") 45 | 46 | raise MaxTriesExceededError 47 | 48 | return wrapper 49 | return decor 50 | 51 | 52 | class TionDelegation: 53 | def __init__(self): 54 | self._data: List[bytearray] = [] 55 | 56 | def handleNotification(self, handle: int, data: bytearray): 57 | self._data.append(data) 58 | _LOGGER.debug(f"Got data in {handle} response {bytes(data).hex()}") 59 | _LOGGER.debug(f"{self._data=}") 60 | 61 | @property 62 | def data(self) -> bytearray: 63 | return self._data.pop(0) 64 | 65 | @property 66 | def haveNewData(self) -> bool: 67 | return len(self._data) > 0 68 | 69 | 70 | class TionException(Exception): 71 | def __init__(self, expression, message): 72 | self.expression = expression 73 | self.message = message 74 | 75 | 76 | class Tion: 77 | statuses = ['off', 'on'] 78 | modes = ['recirculation', 'mixed'] # 'recirculation', 'mixed' and 'outside', as Index exception 79 | uuid_notify: str = "" 80 | uuid_write: str = "" 81 | 82 | def __init__(self, mac: str | BLEDevice): 83 | self._mac = mac 84 | self._btle: BleakClient = BleakClient(mac) 85 | self._next_btle_device: BleakClient | None = None 86 | self._delegation = TionDelegation() 87 | self._fan_speed = 0 88 | self._model: str = self.__class__.__name__ 89 | self._data: bytearray = bytearray() 90 | """Data from breezer response at request state command""" 91 | # states 92 | self._in_temp: int = 0 93 | self._out_temp: int = 0 94 | self._heater_temp: int = 0 95 | self._fan_speed: int = 0 96 | self._mode: int = 0 97 | self._state: bool = False 98 | self._heater: bool = False 99 | self._sound: bool = False 100 | self._filter_remain: float = 0.0 101 | self._error_code: int = 0 102 | self.__failed_connects: int = 0 103 | self.__connections_count: int = 0 104 | self.__notifications_enabled: bool = False 105 | self.have_breezer_state: bool = False 106 | self._semaphore = Semaphore(1) 107 | 108 | @abc.abstractmethod 109 | async def _send_request(self, request: bytearray): 110 | """ Send request to device 111 | 112 | Args: 113 | request : array of bytes to send to device 114 | Returns: 115 | array of bytes with device response 116 | """ 117 | pass 118 | 119 | @abc.abstractmethod 120 | def _decode_response(self, response: bytearray) -> dict: 121 | """ Decode response from device 122 | 123 | Args: 124 | response: array of bytes with data from device, taken from _send_request 125 | Returns: 126 | dictionary with device response 127 | """ 128 | pass 129 | 130 | @abc.abstractmethod 131 | def _encode_request(self, request: dict) -> bytearray: 132 | """ Encode dictionary of request to byte array 133 | 134 | Args: 135 | request: dictionary with request 136 | Returns: 137 | Byte array for sending to device 138 | """ 139 | pass 140 | 141 | @abc.abstractmethod 142 | def _generate_model_specific_json(self) -> dict: 143 | """ 144 | Generates dict with model-specific parameters based on class variables 145 | :return: dict of model specific properties 146 | """ 147 | raise NotImplementedError() 148 | 149 | def __generate_common_json(self) -> dict: 150 | """ 151 | Generates dict with common parameters based on class properties 152 | :return: dict of common properties 153 | """ 154 | return { 155 | "state": self.state, 156 | "heater": self.heater, 157 | "heating": self.heating, 158 | "sound": self.sound, 159 | "mode": self.mode, 160 | "out_temp": self.out_temp, 161 | "in_temp": self.in_temp, 162 | "heater_temp": self._heater_temp, 163 | "fan_speed": self.fan_speed, 164 | "filter_remain": self.filter_remain, 165 | "time": strftime("%H:%M", localtime()), 166 | "request_error_code": self._error_code, 167 | "model": self.model, 168 | } 169 | 170 | @final 171 | @property 172 | def heating(self) -> str: 173 | """Tries to guess is heater working right now.""" 174 | if self.heater == "off": 175 | return "off" 176 | 177 | if self.heater_temp - self.in_temp > 3 and self.out_temp > self.in_temp: 178 | return "on" 179 | 180 | return "off" 181 | 182 | @final 183 | async def get_state_from_breezer(self) -> None: 184 | """ 185 | Get current state from breezer 186 | :return: None 187 | """ 188 | try: 189 | await self.connect() 190 | await self._try_write(request=self.command_getStatus) 191 | response = await self._get_data_from_breezer() 192 | finally: 193 | await self.disconnect() 194 | 195 | self._decode_response(response) 196 | 197 | @final 198 | async def get(self, skip_update: bool = False) -> dict: 199 | """ 200 | Report current breezer state 201 | :param skip_update: may we skip requesting data from breezer or not 202 | :return: 203 | dictionary with device state 204 | """ 205 | if skip_update and self.have_breezer_state: 206 | _LOGGER.debug(f"Skipping getting state from breezer because skip_update={skip_update} and " 207 | f"have_breezer_state={self.have_breezer_state}") 208 | else: 209 | await self.get_state_from_breezer() 210 | common = self.__generate_common_json() 211 | model_specific_data = self._generate_model_specific_json() 212 | 213 | return {**common, **model_specific_data} 214 | 215 | @final 216 | def _set_internal_state_from_request(self, request: dict) -> None: 217 | """ 218 | Set internal parameters based on user request 219 | :param request: changed breezer parameter from set request 220 | :return: None 221 | """ 222 | for p in ['fan_speed', 'heater_temp', 'heater', 'sound', 'mode', 'state']: 223 | # ToDo: lite have additional parameters to set: "light" and "co2_auto_control", so we should get this 224 | # list from class 225 | try: 226 | setattr(self, p, request[p]) 227 | except KeyError: 228 | pass 229 | 230 | @final 231 | async def set(self, new_settings=None) -> None: 232 | """ 233 | Set new breezer state 234 | :param new_settings: json with new state 235 | :return: None 236 | """ 237 | if new_settings is None: 238 | new_settings = {} 239 | 240 | try: 241 | if new_settings["fan_speed"] == 0: 242 | del new_settings["fan_speed"] 243 | new_settings["state"] = "off" 244 | except KeyError: 245 | pass 246 | 247 | try: 248 | await self.connect() 249 | current_settings = await self.get(skip_update=True) 250 | 251 | merged_settings = {**current_settings, **new_settings} 252 | 253 | encoded_request = self._encode_request(merged_settings) 254 | _LOGGER.debug("Will write %s", encoded_request) 255 | await self._send_request(encoded_request) 256 | self._set_internal_state_from_request(new_settings) 257 | await self._get_data_from_breezer() 258 | finally: 259 | await self.disconnect() 260 | 261 | @final 262 | @property 263 | def mac(self): 264 | return self._mac.address if isinstance(self._mac, BLEDevice) else self._mac 265 | 266 | @staticmethod 267 | def decode_temperature(raw: int) -> int: 268 | """ Converts temperature from bytes with addition code to int 269 | Args: 270 | raw: raw temperature value from Tion 271 | Returns: 272 | Integer value for temperature 273 | """ 274 | barrier = 0b10000000 275 | return raw if raw < barrier else -(~(raw - barrier) + barrier + 1) 276 | 277 | @final 278 | def _process_status(self, code: int) -> str: 279 | try: 280 | status = self.statuses[code] 281 | except IndexError: 282 | status = 'unknown' 283 | return status 284 | 285 | @final 286 | @property 287 | def connection_status(self): 288 | status = "connected" if self._btle.is_connected else "disc" 289 | return status 290 | 291 | @final 292 | @retry(retries=1, delay=2) 293 | async def _try_connect(self) -> bool: 294 | """Tries to connect with retries""" 295 | self.set_new_btle_device() 296 | return await self._btle.connect() 297 | 298 | @final 299 | async def _connect(self, need_notifications: bool = True): 300 | _LOGGER.debug(f"Connecting. {self.connection_status=}.") 301 | if self.connection_status == "disc": 302 | try: 303 | await self._try_connect() 304 | except exc.BleakError as e: 305 | _LOGGER.warning(f"Got {str(e)=} exception in _connect") 306 | raise e 307 | 308 | if need_notifications: 309 | await self._enable_notifications() 310 | else: 311 | _LOGGER.debug("Notifications was not requested") 312 | _LOGGER.debug(f"_connect done. {self.connection_status=}.") 313 | 314 | @final 315 | async def _disconnect(self): 316 | _LOGGER.debug(f"Disconnecting. {self.connection_status=}.") 317 | if self.connection_status != "disc": 318 | await self._btle.disconnect() 319 | async with self._semaphore: 320 | self.set_new_btle_device() 321 | 322 | _LOGGER.debug(f"_disconnect done. {self.connection_status=}") 323 | 324 | @final 325 | @retry(retries=3) 326 | async def _try_write(self, request: bytearray): 327 | _LOGGER.debug(f"Writing {bytes(request).hex()} to {self.uuid_write}, {self.connection_status=}") 328 | return await self._btle.write_gatt_char( 329 | self.uuid_write, 330 | request, 331 | False 332 | ) 333 | 334 | @final 335 | async def _enable_notifications(self): 336 | _LOGGER.debug(f"Enabling notification. {self.connection_status=}") 337 | try: 338 | await self._btle.start_notify(self.uuid_notify, self._delegation.handleNotification) 339 | except exc.BleakError as e: 340 | _LOGGER.warning("Got exception %s while enabling notifications!" % str(e)) 341 | raise e 342 | 343 | self.__notifications_enabled = True 344 | _LOGGER.debug(f"_enable_notifications done") 345 | return 346 | 347 | @final 348 | @property 349 | def fan_speed(self): 350 | return self._fan_speed 351 | 352 | @fan_speed.setter 353 | def fan_speed(self, new_speed: int): 354 | if 0 <= new_speed <= 6: 355 | self._fan_speed = new_speed 356 | 357 | else: 358 | _LOGGER.warning("Incorrect new fan speed. Will use 1 instead") 359 | self._fan_speed = 1 360 | 361 | # self.set({"fan_speed": new_speed}) 362 | 363 | @final 364 | def _process_mode(self, mode_code: int) -> str: 365 | try: 366 | mode = self.modes[mode_code] 367 | except IndexError: 368 | mode = 'outside' 369 | return mode 370 | 371 | @staticmethod 372 | def _decode_state(state: bool) -> str: 373 | return "on" if state else "off" 374 | 375 | @staticmethod 376 | def _encode_state(state: str) -> bool: 377 | return state == "on" 378 | 379 | @final 380 | @property 381 | def state(self) -> str: 382 | return self._decode_state(self._state) 383 | 384 | @final 385 | @state.setter 386 | def state(self, new_state: str): 387 | self._state = self._encode_state(new_state) 388 | 389 | @final 390 | @property 391 | def heater(self) -> str: 392 | return self._decode_state(self._heater) 393 | 394 | @final 395 | @heater.setter 396 | def heater(self, new_state: str): 397 | self._heater = self._encode_state(new_state) 398 | 399 | @final 400 | @property 401 | def heater_temp(self) -> int: 402 | return self._heater_temp 403 | 404 | @final 405 | @heater_temp.setter 406 | def heater_temp(self, new_temp: int): 407 | self._heater_temp = new_temp 408 | 409 | @final 410 | @property 411 | def target_temp(self) -> int: 412 | return self.heater_temp 413 | 414 | @final 415 | @target_temp.setter 416 | def target_temp(self, new_temp: int): 417 | self.heater_temp = new_temp 418 | 419 | @final 420 | @property 421 | def in_temp(self): 422 | """Income air temperature""" 423 | return self._in_temp 424 | 425 | @final 426 | @property 427 | def out_temp(self): 428 | """Outcome air temperature""" 429 | return self._out_temp 430 | 431 | @final 432 | @property 433 | def sound(self) -> str: 434 | return self._decode_state(self._sound) 435 | 436 | @final 437 | @sound.setter 438 | def sound(self, new_state: str): 439 | self._sound = self._encode_state(new_state) 440 | 441 | @final 442 | @property 443 | def filter_remain(self) -> float: 444 | return self._filter_remain 445 | 446 | @final 447 | @property 448 | def mode(self): 449 | return self._process_mode(self._mode) 450 | 451 | @final 452 | @mode.setter 453 | def mode(self, new_state: str): 454 | self._mode = self._encode_mode(new_state) 455 | 456 | @final 457 | @property 458 | def model(self) -> str: 459 | return self._model.removeprefix("Tion") 460 | 461 | @final 462 | def _encode_status(self, status: str) -> int: 463 | """ 464 | Encode string status () to int 465 | :param status: one of: "on", "off" 466 | :return: integer equivalent of state 467 | """ 468 | return self.statuses.index(status) if status in self.statuses else 0 469 | 470 | @final 471 | def _encode_mode(self, mode: str) -> int: 472 | """ 473 | Encode string mode to integer 474 | :param mode: one of self.modes + any other as outside 475 | :return: integer equivalent of mode 476 | """ 477 | return self.modes.index(mode) if mode in self.modes else 2 478 | 479 | @final 480 | async def pair(self): 481 | _LOGGER.debug("Pairing") 482 | await self._connect(need_notifications=False) 483 | _LOGGER.debug("Connected. BT pairing ...") 484 | try: 485 | await self._btle.pair() 486 | # device-specific pairing 487 | _LOGGER.debug("Device-specific pairing ...") 488 | await self._pair() 489 | _LOGGER.debug("Device pair is done") 490 | except Exception as e: 491 | _LOGGER.critical(f"Got exception while pair {type(e).__name__}: {str(e)}") 492 | raise TionException('pair', f"{type(e).__name__}: {str(e)}") 493 | finally: 494 | _LOGGER.debug("disconnected") 495 | await self._disconnect() 496 | 497 | @abc.abstractmethod 498 | async def _pair(self): 499 | """Perform model-specific pair steps""" 500 | 501 | @final 502 | async def connect(self): 503 | if self.__connections_count < 0: 504 | self.__connections_count = 0 505 | 506 | if self.__connections_count == 0: 507 | self.have_breezer_state = False 508 | async with self._semaphore: 509 | await self._connect() 510 | 511 | self.__connections_count += 1 512 | 513 | @final 514 | async def disconnect(self): 515 | self.__connections_count -= 1 516 | if self.__connections_count <= 0: 517 | await self._disconnect() 518 | self.have_breezer_state = False 519 | while self._delegation.haveNewData: 520 | _LOGGER.debug(f"Cleaning data in disconnect: {self._delegation.data=}") 521 | 522 | @property 523 | @abc.abstractmethod 524 | def command_getStatus(self) -> bytearray: 525 | raise NotImplementedError() 526 | 527 | @abc.abstractmethod 528 | def _collect_message(self, package: bytearray) -> bool: 529 | """ 530 | Collects message from several package 531 | Must set self._data 532 | 533 | :param package: single package from breezer 534 | :return: Have we full response from breezer or not 535 | """ 536 | raise NotImplementedError() 537 | 538 | @final 539 | async def _get_data_from_breezer(self) -> bytearray: 540 | """ Get byte array with breezer response on state request 541 | 542 | :returns: 543 | breezer response 544 | """ 545 | self.have_breezer_state = False 546 | 547 | _LOGGER.debug("Collecting data") 548 | 549 | i = 0 550 | 551 | while i < 10: 552 | if self._delegation.haveNewData: 553 | byte_response = self._delegation.data 554 | if self._collect_message(byte_response): 555 | self.have_breezer_state = True 556 | break 557 | i = 0 558 | else: 559 | await asyncio.sleep(1) 560 | i += 1 561 | else: 562 | _LOGGER.debug("Waiting too long for data") 563 | 564 | if self.have_breezer_state: 565 | result = self._data 566 | 567 | else: 568 | raise TionException("_get_data_from_breezer", "Could not get breezer state") 569 | 570 | return result 571 | 572 | @final 573 | def update_btle_device(self, new_device: str | BLEDevice): 574 | if new_device is None: 575 | _LOGGER.info(f"Skipping update due to {new_device= }!") 576 | return 577 | self._next_btle_device = new_device 578 | 579 | @final 580 | def set_new_btle_device(self): 581 | if self._next_btle_device is not None: 582 | try: 583 | _LOGGER.debug(f"Updating _btle instance from {self._btle} to {self._next_btle_device}") 584 | except AttributeError: 585 | pass 586 | 587 | self._btle = BleakClient(self._next_btle_device) 588 | self._next_btle_device = None 589 | --------------------------------------------------------------------------------