├── .github ├── ISSUE_TEMPLATE │ ├── -------------------.md │ └── bug_report.md └── workflows │ ├── close_stale.yml │ ├── manual_release.yaml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── custom_components └── ha_tion_btle │ ├── __init__.py │ ├── climate.py │ ├── config_flow.py │ ├── const.py │ ├── fan.py │ ├── manifest.json │ ├── manifest.json.tpl │ ├── select.py │ ├── sensor.py │ ├── services.yaml │ └── translations │ └── en.json ├── hacs.json └── info.md /.github/ISSUE_TEMPLATE/-------------------.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Сообщить о проблеме 3 | about: Постараемся помочь если у вас возникли проблемы 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Общая информация 11 | * **версия Home Assistant**: 12 | * **способ установки Home Assistant**: 13 | [ ] venv 14 | [ ] Docker 15 | [ ] Home Assistant OS 16 | * **версия компонента**: 17 | * **модель бризера**: 18 | * **версия python-модуля**: 19 | 20 | [ ] в момент проявления проблемы к бризеру никто не подключен 21 | [ ] bluetooth в системе работает корректно, действий из [WiKi](https://github.com/TionAPI/HA-tion/wiki/Bluetooth) не требуется 22 | [ ] проблема не похожа ни на одну из [FAQ](https://github.com/TionAPI/HA-tion/wiki/FAQ) 23 | 24 | ## Краткое описание 25 | 26 | 27 | ## Debug-log 28 | ``` 29 | Скопируйте содержиме home-asssistant.log с включенным режимом отладки для интеграции. 30 | От момена до действия до возникновения проблемы. 31 | ``` 32 | 33 | ## Как можно воспроизвети вашу проблему 34 | 35 | 1. действте 1 36 | 1. действие 2 37 | 1. действие ... 38 | 39 | ## Дополнительное описание 40 | 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Общая информация 11 | * **версия Home Assistant**: 12 | * **способ установки Home Assistant**: 13 | [ ] venv 14 | [ ] Docker 15 | [ ] Home Assistant OS 16 | * **версия компонента**: 17 | * **Модель бризера**: 18 | * **версия python-модуля**: 19 | [ ] в момент проявления проблемы к бризеру никто не подключен 20 | [ ] bluetooth в системе работает корректно, действий из (WiKi)[https://github.com/TionAPI/HA-tion/wiki/Bluetooth] не требуется 21 | [ ] проблема не похожа ни на одну из (FAQ)[https://github.com/TionAPI/HA-tion/wiki/FAQ] 22 | ## Краткое описание 23 | 24 | 25 | ## Debug-log 26 | ``` 27 | Скопируйте содержиме home-asssistant.log с включенным режимом отладки для интеграции. 28 | От момена до действия до возникновения проблемы. 29 | ``` 30 | 31 | ## Как можно воспроизвети вашу проблему 32 | 33 | 1. действте 1 34 | 1. действие 2 35 | 1. действие ... 36 | 37 | ## Дополнительное описание 38 | 39 | -------------------------------------------------------------------------------- /.github/workflows/close_stale.yml: -------------------------------------------------------------------------------- 1 | name: "Close stale issues" 2 | on: 3 | schedule: 4 | - cron: "0 3 * * *" 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v3 11 | with: 12 | repo-token: ${{ secrets.GITHUB_TOKEN }} 13 | stale-issue-label: stale 14 | exempt-issue-labels: 'work in progress' 15 | only-labels: 'need more info' 16 | stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 30 days' 17 | close-issue-message: 'Automatically closed: no activity for 30 days after stale flag' 18 | stale-pr-message: none 19 | days-before-stale: 30 20 | days-before-close: 30 21 | -------------------------------------------------------------------------------- /.github/workflows/manual_release.yaml: -------------------------------------------------------------------------------- 1 | # Make new release based on conventional commits 2 | name: Manual create release 3 | 4 | # yamllint disable-line rule:truthy 5 | on: 6 | workflow_dispatch: 7 | inputs: 8 | tag: 9 | description: 'tag to release' 10 | required: true 11 | 12 | jobs: 13 | version: 14 | name: "Update version" 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: "Check out repository" 18 | uses: actions/checkout@v2 19 | - name: setup python 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: '3.10' 23 | 24 | - name: "Setup environment" 25 | run: | 26 | git config user.name "GitHub Actions Bot" 27 | git config user.email "github-actions@no_spam.please" 28 | 29 | - name: Update version file 30 | id: update 31 | run: sed -e "s/%%%VERSION%%%/${{ github.event.inputs.tag }}/" ./custom_components/ha_tion_btle/manifest.json.tpl >custom_components/ha_tion_btle/manifest.json 32 | 33 | - name: Commit version 34 | id: commit_version 35 | run: | 36 | git commit -m "chore(release): ${{ github.event.inputs.tag }}" custom_components/ha_tion_btle/manifest.json 37 | 38 | - name: Update 39 | id: update_tag 40 | run: | 41 | git push origin master && \ 42 | git tag -f -a -m "v${{ github.event.inputs.tag }}" v${{ github.event.inputs.tag }} && git push -f --tags 43 | 44 | release: 45 | name: "Create release" 46 | needs: [version] 47 | runs-on: ubuntu-latest 48 | 49 | steps: 50 | - name: "Check out repository" 51 | uses: actions/checkout@v2 52 | with: 53 | ref: v${{ github.event.inputs.tag }} 54 | 55 | - name: "Set package name" 56 | working-directory: ./custom_components 57 | run: | 58 | echo "package=$(ls -F | grep \/$ | sed -n "s/\///g;1p")" >> $GITHUB_ENV 59 | 60 | - name: "Set variables" 61 | working-directory: ./custom_components 62 | run: | 63 | echo "archive=${{ env.package }}.zip" >> $GITHUB_ENV 64 | echo "basedir=$(pwd)/${{ env.package }}" >> $GITHUB_ENV 65 | env 66 | 67 | - name: "Zip component dir" 68 | working-directory: ./custom_components/${{ env.package }} 69 | run: | 70 | rm -f manifest.json.tpl 71 | zip ${{ env.archive }} -r ./ 72 | 73 | - name: Create Release 74 | id: release 75 | uses: softprops/action-gh-release@v1 76 | with: 77 | tag_name: v${{ github.event.inputs.tag }} 78 | name: v${{ github.event.inputs.tag }} 79 | draft: true 80 | files: ${{ env.basedir }}/${{ env.archive }} 81 | body: | 82 | [![GitHub release (by tag)](https://img.shields.io/github/downloads/${{ github.repository }}/v${{ github.event.inputs.tag }}/total?style=plastic)](https://github.com/${{ github.repository }}/releases/tag/v${{ github.event.inputs.tag }}) 83 | "Put changelog here" 84 | ${{ steps.footer.outputs.content }} 85 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Make new release based on conventional commits 2 | name: Create release 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | workflow_dispatch: 9 | 10 | jobs: 11 | changes: 12 | name: "Create changelog and tag" 13 | runs-on: ubuntu-latest 14 | outputs: 15 | skipped: ${{ steps.changelog.outputs.skipped }} 16 | clean_changelog: ${{ steps.changelog.outputs.clean_changelog }} 17 | tag: ${{ steps.changelog.outputs.tag }} 18 | 19 | steps: 20 | - name: checkout 21 | uses: actions/checkout@v2 22 | id: checkout 23 | 24 | - name: Conventional Changelog Action 25 | id: changelog 26 | uses: TriPSs/conventional-changelog-action@v3 27 | with: 28 | github-token: ${{ secrets.github_token }} 29 | output-file: "false" 30 | skip-version-file: "true" 31 | skip-commit: "true" 32 | version: 33 | name: "Update version" 34 | needs: changes 35 | if: ${{ needs.changes.outputs.skipped == 'false' }} 36 | runs-on: ubuntu-latest 37 | steps: 38 | - name: "Check out repository" 39 | uses: actions/checkout@v2 40 | 41 | - name: "Prepare" 42 | run: | 43 | echo "NEW_VERSION=${{ needs.changes.outputs.tag }}" | sed -e 's/=v/=/' >> $GITHUB_ENV 44 | # setup the username and email. I tend to use 'GitHub Actions Bot' with no email by default 45 | git config user.name "GitHub Actions Bot" 46 | git config user.email "github-actions@no_spam.please" 47 | - name: Update version file 48 | id: update 49 | run: sed -e "s/%%%VERSION%%%/${{ env.NEW_VERSION }}/" ./custom_components/ha_tion_btle/manifest.json.tpl >custom_components/ha_tion_btle/manifest.json 50 | 51 | - name: Commit 52 | id: commit 53 | run: | 54 | git commit -m "chore(release): version update to ${{ env.NEW_VERSION }}" custom_components/ha_tion_btle/manifest.json && git push origin master || true 55 | git tag -f -a -m "v${{ env.NEW_VERSION }}" v${{ env.NEW_VERSION }} && git push -f --tags || true 56 | release: 57 | name: "Create release" 58 | needs: [ changes, version ] 59 | if: ${{ needs.changes.outputs.skipped == 'false' }} 60 | runs-on: ubuntu-latest 61 | 62 | steps: 63 | - name: Create Release 64 | id: release 65 | uses: actions/create-release@v1 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.github_token }} 68 | with: 69 | tag_name: ${{ needs.changes.outputs.tag }} 70 | release_name: ${{ needs.changes.outputs.tag }} 71 | body: | 72 | [![GitHub release (by tag)](https://img.shields.io/github/downloads/${{ github.repository }}/${{ needs.changes.outputs.tag }}/total?style=plastic)]() 73 | ${{ needs.changes.outputs.clean_changelog }} 74 | 75 | add_archive_to_release: 76 | name: "Add release archive" 77 | needs: [ 'changes', 'release' ] 78 | if: ${{ needs.changes.outputs.skipped == 'false' }} 79 | runs-on: ubuntu-latest 80 | steps: 81 | - name: "Check out repository" 82 | uses: actions/checkout@v1 83 | with: 84 | ref: "${{ needs.changes.outputs.tag }}" 85 | - name: "Set package name" 86 | working-directory: ./custom_components 87 | run: | 88 | echo "package=$(ls -F | grep \/$ | sed -n "s/\///g;1p")" >> $GITHUB_ENV 89 | - name: "Set variables" 90 | working-directory: ./custom_components 91 | run: | 92 | echo "archive=${{ env.package }}.zip" >> $GITHUB_ENV 93 | echo "basedir=$(pwd)/${{ env.package }}" >> $GITHUB_ENV 94 | env 95 | - name: "Zip component dir" 96 | working-directory: ./custom_components/${{ env.package }} 97 | run: | 98 | zip ${{ env.archive }} -r ./ 99 | - name: "Upload zip to release" 100 | uses: svenstaro/upload-release-action@v2 101 | with: 102 | repo_token: ${{ secrets.github_token }} 103 | file: ${{ env.basedir }}/${{ env.archive }} 104 | asset_name: ${{ env.archive }} 105 | tag: ${{ needs.changes.outputs.tag }} 106 | overwrite: true 107 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .venv 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![version_badge](https://img.shields.io/badge/minimum%20HA%20version-2022.08-red) 2 | # Custom integration for Tion S3, S4 and Lite breezers for Home Assistant 3 | This custom integration will allow your Home assistant to control: 4 | * fan speed 5 | * target heater temp 6 | * heater mode (on/off) 7 | * some presets: 8 | * boost 9 | * away 10 | 11 | of your Tion S3/S4/Lite breezer via bluetooth. If you are prefer control breezer via Magic Air, please follow to https://github.com/airens/tion_home_assistant repository. 12 | 13 | :warning: Please remember that breezer is not heating device and don't try use it for room heating :warning: 14 | #### disclaimer: everything that you do, you do at your own peril and risk 15 | 16 | # How to use 17 | ## Requirements 18 | 1. BTLE supported host with Home Assistant 19 | 1. Tion S3, S4 or Lite breezer 20 | 21 | ## Installation & configuration 22 | ### HACS installation 23 | 1. goto HACS->Integrations->three dot at upper-right conner->Custom repositories; 24 | 1. add TionAPI/HA-tion to ADD CUSTOM REPOSITORY field and select Integration in CATEGORY; 25 | 1. click "add" button; 26 | 1. find "Tion breezer" integration; 27 | 1. click "Install". Home assistant restart may be required; 28 | 29 | ### Configuration via User interface 30 | 1. go to Integrations page; 31 | 1. click "plus" button; 32 | 1. type "Tion" in search field; 33 | 1. click on "Tion breezer integration"; 34 | 1. fill fields; 35 | 1. click "Next" and follow instructions; 36 | 1. restart Home Assistant. 37 | 38 | Repeat this steps for every device that you are going to use with home assistant. 39 | 40 | ## Usage 41 | ### Turning on / Turning off 42 | * calling `climate.set_hvac_mode`. Mode: 43 | * `off` will turn off breezer; 44 | * `fan_only` will turn brezzer on with turned off heater 45 | * `heat` will turn breezer on with tunrned on heater 46 | fan speed will not be changed. 47 | * calling `climate.set_fan_mode`. 48 | * `0` will turn off breezer; 49 | * `1`..`6` will turn breezer on. 50 | No state (`heater`/`fan_only`) will be changed. 51 | * ![added_in_version_badge](https://img.shields.io/badge/Since-v2.1.3-red) you may use `climate.turn_on` and `climate.turn_off` services. `climate.turn_on` will turn on breezer into the state it was before being turned off. 52 | 53 | ### Automation example 54 | automations.yaml: 55 | ```yaml 56 | - id: 'tion1' 57 | alias: 1 speed for tion by co2 < 500 58 | trigger: 59 | - platform: numeric_state 60 | entity_id: sensor.mhz19_co2 61 | below: '500' 62 | for: 00:05:00 63 | condition: 64 | - condition: not 65 | conditions: 66 | - condition: state 67 | entity_id: climate.tion_breezer 68 | state: 'off' 69 | action: 70 | - service: climate.set_fan_mode 71 | entity_id: climate.tion_breezer 72 | data: 73 | fan_mode: 1 74 | 75 | - id: 'tion4' 76 | alias: 4 speed for tion with co2 > 600 77 | trigger: 78 | - platform: numeric_state 79 | entity_id: sensor.mhz19_co2 80 | above: '600' 81 | for: 00:05:00 82 | condition: 83 | - condition: time #don't turn on fan at speed 4 from 22:00 to 08:00 84 | after: '08:00:00' 85 | before: '22:00:00' 86 | - condition: not 87 | conditions: 88 | - condition: state 89 | entity_id: climate.tion_breezer 90 | state: 'off' 91 | action: 92 | - service: climate.set_fan_mode 93 | entity_id: climate.tion_breezer 94 | data: 95 | fan_mode: 4 96 | ``` 97 | ## Error reporting 98 | Feel free to open issues. 99 | Please attach debug log to issue. 100 | For turning on debug log level you may use following logger settings in configuration.yaml: 101 | ```yaml 102 | logger: 103 | default: warning 104 | logs: 105 | custom_components.ha_tion_btle: debug 106 | tion_btle.tion: debug 107 | tion_btle.s3: debug 108 | tion_btle.lite: debug 109 | tion_btle.s4: debug 110 | custom_components.ha_tion_btle.config_flow: debug 111 | ``` 112 | -------------------------------------------------------------------------------- /custom_components/ha_tion_btle/__init__.py: -------------------------------------------------------------------------------- 1 | """The Tion breezer component.""" 2 | from __future__ import annotations 3 | 4 | from bleak.backends.device import BLEDevice 5 | import datetime 6 | import logging 7 | import math 8 | from datetime import timedelta 9 | from functools import cached_property 10 | 11 | import tion_btle 12 | from homeassistant.components import bluetooth 13 | from homeassistant.components.bluetooth import BluetoothCallbackMatcher 14 | from homeassistant.exceptions import ConfigEntryNotReady 15 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 16 | from tion_btle.tion import Tion, MaxTriesExceededError 17 | from .const import DOMAIN, TION_SCHEMA, CONF_KEEP_ALIVE, CONF_AWAY_TEMP, CONF_MAC, PLATFORMS 18 | from homeassistant.config_entries import ConfigEntry 19 | from homeassistant.core import HomeAssistant, callback 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | 24 | async def async_setup(hass, config): 25 | return True 26 | 27 | 28 | async def async_setup_entry(hass, config_entry: ConfigEntry): 29 | _LOGGER.info("Setting up %s ", config_entry.unique_id) 30 | 31 | hass.data.setdefault(DOMAIN, {}) 32 | 33 | instance = TionInstance(hass, config_entry) 34 | hass.data[DOMAIN][config_entry.unique_id] = instance 35 | config_entry.async_on_unload( 36 | bluetooth.async_register_callback( 37 | hass=hass, 38 | callback=instance.update_btle_device, 39 | match_dict=BluetoothCallbackMatcher(address=instance.config[CONF_MAC], connectable=True), 40 | mode=bluetooth.BluetoothScanningMode.ACTIVE, 41 | ) 42 | ) 43 | 44 | await hass.data[DOMAIN][config_entry.unique_id].async_config_entry_first_refresh() 45 | 46 | result = True 47 | for platform in PLATFORMS: 48 | result = result & await hass.config_entries.async_forward_entry_setup(entry=config_entry, domain=platform) 49 | return result 50 | 51 | 52 | class TionInstance(DataUpdateCoordinator): 53 | def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry): 54 | 55 | self._config_entry: ConfigEntry = config_entry 56 | 57 | assert self.config[CONF_MAC] is not None 58 | # https://developers.home-assistant.io/docs/network_discovery/#fetching-the-bleak-bledevice-from-the-address 59 | btle_device = bluetooth.async_ble_device_from_address(hass, self.config[CONF_MAC], connectable=True) 60 | if btle_device is None: 61 | raise ConfigEntryNotReady 62 | 63 | self.__keep_alive: int = 60 64 | try: 65 | self.__keep_alive = self.config[CONF_KEEP_ALIVE] 66 | except KeyError: 67 | pass 68 | 69 | # delay before next update if we got btle.BTLEDisconnectError 70 | self._delay: int = 600 71 | 72 | self.__tion: Tion = self.getTion(self.model, btle_device) 73 | self.__keep_alive = datetime.timedelta(seconds=self.__keep_alive) 74 | self._delay = datetime.timedelta(seconds=self._delay) 75 | self.rssi: int = 0 76 | 77 | if self._config_entry.unique_id is None: 78 | _LOGGER.critical(f"Unique id is None for {self._config_entry.title}! " 79 | f"Will fix it by using {self.unique_id}") 80 | hass.config_entries.async_update_entry( 81 | entry=self._config_entry, 82 | unique_id=self.unique_id, 83 | ) 84 | _LOGGER.critical("Done! Please restart Home Assistant.") 85 | 86 | super().__init__( 87 | name=self.config['name'] if 'name' in self.config else TION_SCHEMA['name']['default'], 88 | hass=hass, 89 | logger=_LOGGER, 90 | update_interval=self.__keep_alive, 91 | update_method=self.async_update_state, 92 | ) 93 | 94 | @property 95 | def config(self) -> dict: 96 | try: 97 | data = dict(self._config_entry.data or {}) 98 | except AttributeError: 99 | data = {} 100 | 101 | try: 102 | options = self._config_entry.options or {} 103 | data.update(options) 104 | except AttributeError: 105 | pass 106 | return data 107 | 108 | @staticmethod 109 | def _decode_state(state: str) -> bool: 110 | return True if state == "on" else False 111 | 112 | async def async_update_state(self): 113 | self.logger.info("Tion instance update started") 114 | response: dict[str, str | bool | int] = {} 115 | 116 | try: 117 | response = await self.__tion.get() 118 | self.update_interval = self.__keep_alive 119 | 120 | except MaxTriesExceededError as e: 121 | _LOGGER.critical("Got exception %s", str(e)) 122 | _LOGGER.critical("Will delay next check") 123 | self.update_interval = self._delay 124 | raise UpdateFailed("MaxTriesExceededError") 125 | except Exception as e: 126 | _LOGGER.critical(f"{response=}, {e=}") 127 | raise e 128 | 129 | response["is_on"]: bool = self._decode_state(response["state"]) 130 | response["heater"]: bool = self._decode_state(response["heater"]) 131 | response["is_heating"] = self._decode_state(response["heating"]) 132 | response["filter_remain"] = math.ceil(response["filter_remain"]) 133 | response["fan_speed"] = int(response["fan_speed"]) 134 | response["rssi"] = self.rssi 135 | 136 | self.logger.debug(f"Result is {response}") 137 | return response 138 | 139 | @property 140 | def away_temp(self) -> int: 141 | """Temperature for away mode""" 142 | return self.config[CONF_AWAY_TEMP] if CONF_AWAY_TEMP in self.config else TION_SCHEMA[CONF_AWAY_TEMP]['default'] 143 | 144 | async def set(self, **kwargs): 145 | if "fan_speed" in kwargs: 146 | kwargs["fan_speed"] = int(kwargs["fan_speed"]) 147 | 148 | original_args = kwargs.copy() 149 | if "is_on" in kwargs: 150 | kwargs["state"] = "on" if kwargs["is_on"] else "off" 151 | del kwargs["is_on"] 152 | if "heater" in kwargs: 153 | kwargs["heater"] = "on" if kwargs["heater"] else "off" 154 | 155 | args = ', '.join('%s=%r' % x for x in kwargs.items()) 156 | _LOGGER.info("Need to set: " + args) 157 | await self.__tion.set(kwargs) 158 | self.data.update(original_args) 159 | self.async_update_listeners() 160 | 161 | @staticmethod 162 | def getTion(model: str, mac: str | BLEDevice) -> tion_btle.TionS3 | tion_btle.TionLite | tion_btle.TionS4: 163 | if model == 'S3': 164 | from tion_btle.s3 import TionS3 as Breezer 165 | elif model == 'S4': 166 | from tion_btle.s4 import TionS4 as Breezer 167 | elif model == 'Lite': 168 | from tion_btle.lite import TionLite as Breezer 169 | else: 170 | raise NotImplementedError("Model '%s' is not supported!" % model) 171 | return Breezer(mac) 172 | 173 | async def connect(self): 174 | return await self.__tion.connect() 175 | 176 | async def disconnect(self): 177 | return await self.__tion.disconnect() 178 | 179 | @property 180 | def device_info(self): 181 | info = {"identifiers": {(DOMAIN, self.unique_id)}, "name": self.name, "manufacturer": "Tion", 182 | "model": self.data.get("model")} 183 | if self.data.get("fw_version") is not None: 184 | info['sw_version'] = self.data.get("fw_version") 185 | return info 186 | 187 | @cached_property 188 | def unique_id(self): 189 | return self.config[CONF_MAC] 190 | 191 | @cached_property 192 | def supported_air_sources(self) -> list[str]: 193 | if self.model == "S3": 194 | return ["outside", "mixed", "recirculation"] 195 | else: 196 | return ["outside", "recirculation"] 197 | 198 | @cached_property 199 | def model(self) -> str: 200 | try: 201 | model = self.config['model'] 202 | except KeyError: 203 | _LOGGER.warning(f"Model was not found in config. " 204 | f"Please update integration settings! Config is {self.config}") 205 | _LOGGER.warning("Assume that model is S3") 206 | model = 'S3' 207 | return model 208 | 209 | @callback 210 | def update_btle_device( 211 | self, 212 | service_info: bluetooth.BluetoothServiceInfoBleak, 213 | _change: bluetooth.BluetoothChange 214 | ) -> None: 215 | if service_info.device is not None: 216 | self.rssi = service_info.rssi 217 | self.__tion.update_btle_device(service_info.device) 218 | -------------------------------------------------------------------------------- /custom_components/ha_tion_btle/climate.py: -------------------------------------------------------------------------------- 1 | """Adds support for generic thermostat units.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | 6 | from homeassistant.config_entries import ConfigEntry 7 | from homeassistant.core import HomeAssistant 8 | from homeassistant.const import UnitOfTemperature 9 | import voluptuous as vol 10 | 11 | from homeassistant.components.climate import ClimateEntity, ClimateEntityFeature, HVACMode, HVACAction 12 | 13 | from homeassistant.helpers import config_validation as cv, entity_platform 14 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 15 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 16 | 17 | from . import TionInstance 18 | from .const import * 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 23 | { 24 | vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, 25 | vol.Required(CONF_MAC): cv.string, 26 | vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float), 27 | vol.Optional(CONF_KEEP_ALIVE, default=30): vol.All(cv.time_period, cv.positive_timedelta), 28 | vol.Optional(CONF_INITIAL_HVAC_MODE): vol.In( 29 | [HVACMode.FAN_ONLY, HVACMode.HEAT, HVACMode.OFF] 30 | ), 31 | vol.Optional(CONF_AWAY_TEMP): vol.Coerce(float), 32 | } 33 | ) 34 | 35 | devices = [] 36 | 37 | 38 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback): 39 | """Setup entry""" 40 | tion_instance: TionInstance = hass.data[DOMAIN][config_entry.unique_id] 41 | unique_id = tion_instance.unique_id 42 | 43 | if unique_id not in devices: 44 | devices.append(unique_id) 45 | async_add_entities([TionClimateEntity(hass, tion_instance)]) 46 | else: 47 | _LOGGER.warning(f"Device {unique_id} is already configured! ") 48 | 49 | platform = entity_platform.async_get_current_platform() 50 | platform.async_register_entity_service( 51 | "set_air_source", 52 | { 53 | vol.Required("source"): vol.In(tion_instance.supported_air_sources), 54 | }, 55 | "set_air_source", 56 | ) 57 | 58 | return True 59 | 60 | 61 | class TionClimateEntity(ClimateEntity, CoordinatorEntity): 62 | """Representation of a Tion device.""" 63 | 64 | _attr_hvac_modes = [HVACMode.HEAT, HVACMode.FAN_ONLY, HVACMode.OFF] 65 | _attr_min_temp = 0 66 | _attr_max_temp = 30 67 | _attr_fan_modes = [1, 2, 3, 4, 5, 6] 68 | _attr_precision = PRECISION_WHOLE 69 | _attr_target_temperature_step = 1 70 | _attr_temperature_unit = UnitOfTemperature.CELSIUS 71 | _attr_preset_modes = [PRESET_NONE, PRESET_BOOST, PRESET_SLEEP] 72 | _attr_preset_mode = PRESET_NONE 73 | _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.PRESET_MODE 74 | _attr_icon = 'mdi:air-purifier' 75 | _attr_fan_mode: int 76 | coordinator: TionInstance 77 | 78 | def __init__(self, hass: HomeAssistant, instance: TionInstance): 79 | CoordinatorEntity.__init__( 80 | self=self, 81 | coordinator=instance, 82 | ) 83 | self.hass: HomeAssistant = hass 84 | self._away_temp = self.coordinator.away_temp 85 | 86 | # saved states 87 | self._last_mode: HVACMode | None = None 88 | self._saved_target_temp = None 89 | self._saved_fan_mode = None 90 | 91 | # current state 92 | self._target_temp = None 93 | self._is_boost: bool = False 94 | self._fan_speed = 1 95 | 96 | if self._away_temp: 97 | self._attr_preset_modes.append(PRESET_AWAY) 98 | 99 | self._attr_device_info = self.coordinator.device_info 100 | self._attr_name = self.coordinator.name 101 | self._attr_unique_id = self.coordinator.unique_id 102 | 103 | self._get_current_state() 104 | ClimateEntity.__init__(self) 105 | 106 | async def async_set_hvac_mode(self, hvac_mode: HVACMode): 107 | """Set hvac mode.""" 108 | _LOGGER.info("Need to set mode to %s, current mode is %s", hvac_mode, self.hvac_mode) 109 | if self.hvac_mode == hvac_mode: 110 | # Do nothing if mode is same 111 | _LOGGER.debug(f"{self.name} is asked for mode {hvac_mode}, but it is already in {self.hvac_mode}. Do " 112 | f"nothing.") 113 | pass 114 | elif hvac_mode == HVACMode.OFF: 115 | # Keep last mode while turning off. May be used while calling climate turn_on service 116 | self._last_mode = self.hvac_mode 117 | await self._async_set_state(is_on=False) 118 | 119 | elif hvac_mode == HVACMode.HEAT: 120 | saved_target_temp = self.target_temperature 121 | try: 122 | await self.coordinator.connect() 123 | await self._async_set_state(heater=True, is_on=True) 124 | if self.hvac_mode == HVACMode.FAN_ONLY: 125 | await self.async_set_temperature(**{ATTR_TEMPERATURE: saved_target_temp}) 126 | finally: 127 | await self.coordinator.disconnect() 128 | elif hvac_mode == HVACMode.FAN_ONLY: 129 | await self._async_set_state(heater=False, is_on=True) 130 | 131 | else: 132 | _LOGGER.error("Unrecognized hvac mode: %s", hvac_mode) 133 | return 134 | # Ensure we update the current operation after changing the mode 135 | self._handle_coordinator_update() 136 | 137 | async def async_set_preset_mode(self, preset_mode: str): 138 | """Set new preset mode.""" 139 | actions = [] 140 | _LOGGER.debug("Going to change preset mode from %s to %s", self.preset_mode, preset_mode) 141 | if preset_mode == PRESET_AWAY and self.preset_mode != PRESET_AWAY: 142 | _LOGGER.info("Going to AWAY mode. Will save target temperature %s", self.target_temperature) 143 | self._saved_target_temp = self.target_temperature 144 | actions.append([self._async_set_state, {'heater_temp': self._away_temp}]) 145 | 146 | if preset_mode != PRESET_AWAY and self.preset_mode == PRESET_AWAY and self._saved_target_temp: 147 | # retuning from away mode 148 | _LOGGER.info("Returning from AWAY mode: will set saved temperature %s", self._saved_target_temp) 149 | actions.append([self._async_set_state, {'heater_temp': self._saved_target_temp}]) 150 | self._saved_target_temp = None 151 | 152 | if preset_mode == PRESET_SLEEP and self.preset_mode != PRESET_SLEEP: 153 | _LOGGER.info("Going to night mode: will save fan_speed: %s", self.fan_mode) 154 | if self._saved_fan_mode is None: 155 | self._saved_fan_mode = int(self.fan_mode) 156 | actions.append([self.async_set_fan_mode, {'fan_mode': min(int(self.fan_mode), self.sleep_max_fan_mode)}]) 157 | 158 | if preset_mode == PRESET_BOOST and not self._is_boost: 159 | self._is_boost = True 160 | if self._saved_fan_mode is None: 161 | self._saved_fan_mode = int(self.fan_mode) 162 | actions.append([self.async_set_fan_mode, {'fan_mode': self.boost_fan_mode}]) 163 | 164 | if self.preset_mode in [PRESET_BOOST, PRESET_SLEEP] and preset_mode not in [PRESET_BOOST, PRESET_SLEEP]: 165 | # returning from boost or sleep mode 166 | _LOGGER.info("Returning from %s mode. Going to set fan speed %d", self.preset_mode, self._saved_fan_mode) 167 | if self.preset_mode == PRESET_BOOST: 168 | self._is_boost = False 169 | 170 | if self._saved_fan_mode is not None: 171 | actions.append([self.async_set_fan_mode, {'fan_mode': self._saved_fan_mode}]) 172 | self._saved_fan_mode = None 173 | 174 | self._attr_preset_mode = preset_mode 175 | try: 176 | await self.coordinator.connect() 177 | for a in actions: 178 | await a[0](**a[1]) 179 | self._attr_preset_mode = preset_mode 180 | self._handle_coordinator_update() 181 | finally: 182 | await self.coordinator.disconnect() 183 | 184 | self._handle_coordinator_update() 185 | 186 | @property 187 | def boost_fan_mode(self) -> int: 188 | """Fan speed for boost mode 189 | 190 | :return: maximum of supported fan_modes 191 | """ 192 | return max([int(x) for x in self.fan_modes]) 193 | 194 | @property 195 | def sleep_max_fan_mode(self) -> int: 196 | """Maximum fan speed for sleep mode""" 197 | return 2 198 | 199 | async def async_set_fan_mode(self, fan_mode): 200 | if self.preset_mode == PRESET_SLEEP: 201 | if int(fan_mode) > self.sleep_max_fan_mode: 202 | _LOGGER.info("Fan speed %s was required, but I'm in SLEEP mode, so it should not be greater than %d", 203 | self.sleep_max_fan_mode) 204 | fan_mode = self.sleep_max_fan_mode 205 | 206 | if (self.preset_mode == PRESET_BOOST and self._is_boost) and fan_mode != self.boost_fan_mode: 207 | _LOGGER.debug("I'm in boost mode. Will ignore requested fan speed %s" % fan_mode) 208 | fan_mode = self.boost_fan_mode 209 | if fan_mode != self.fan_mode or not self.coordinator.data.get("is_on"): 210 | self._fan_speed = fan_mode 211 | await self._async_set_state(fan_speed=fan_mode, is_on=True) 212 | 213 | async def async_set_temperature(self, **kwargs): 214 | """Set new target temperature.""" 215 | temperature = kwargs.get(ATTR_TEMPERATURE) 216 | if temperature is None: 217 | return 218 | self._target_temp = temperature 219 | await self._async_set_state(heater_temp=temperature) 220 | 221 | async def async_turn_on(self): 222 | """ 223 | Turn breezer on. Tries to restore last state. Use HEAT as backup 224 | """ 225 | _LOGGER.debug(f"Turning on from {self.hvac_mode} to {self._last_mode}") 226 | if self.hvac_mode != HVACMode.OFF: 227 | # do nothing if we already working 228 | pass 229 | elif self._last_mode is None: 230 | await self.async_set_hvac_mode(HVACMode.HEAT) 231 | else: 232 | await self.async_set_hvac_mode(self._last_mode) 233 | 234 | async def async_turn_off(self): 235 | _LOGGER.debug(f"Turning off from {self.hvac_mode}") 236 | await self.async_set_hvac_mode(HVACMode.OFF) 237 | 238 | async def _async_set_state(self, **kwargs): 239 | await self.coordinator.set(**kwargs) 240 | self._handle_coordinator_update() 241 | 242 | def _handle_coordinator_update(self) -> None: 243 | self._get_current_state() 244 | if int(self.fan_mode) != self.boost_fan_mode and (self._is_boost or self.preset_mode == PRESET_BOOST): 245 | _LOGGER.warning(f"I'm in boost mode, but current speed {self.fan_mode} is not equal boost speed " 246 | f"{self.boost_fan_mode}. Dropping boost mode") 247 | self._is_boost = False 248 | self._attr_preset_mode = PRESET_NONE 249 | 250 | self.async_write_ha_state() 251 | 252 | def _get_current_state(self): 253 | self._attr_target_temperature = self.coordinator.data.get("heater_temp") 254 | self._attr_current_temperature = self.coordinator.data.get("out_temp") 255 | self._attr_fan_mode = self.coordinator.data.get("fan_speed") 256 | self._attr_assumed_state = False if self.coordinator.last_update_success else True 257 | self._attr_extra_state_attributes = { 258 | 'air_mode': self.coordinator.data.get("air_mode"), 259 | 'in_temp': self.coordinator.data.get("in_temp") 260 | } 261 | self._attr_hvac_mode = HVACMode.OFF if not self.coordinator.data.get("is_on") else \ 262 | HVACMode.HEAT if self.coordinator.data.get("heater") else HVACMode.FAN_ONLY 263 | self._attr_hvac_action = HVACAction.OFF if not self.coordinator.data.get("is_on") else \ 264 | HVACAction.HEATING if self.coordinator.data.get("is_heating") else HVACAction.FAN 265 | 266 | @property 267 | def available(self) -> bool: 268 | """Return if entity is available.""" 269 | return True 270 | 271 | async def set_air_source(self, source: str): 272 | _LOGGER.debug(f"set_air_source: {source}") 273 | await self.coordinator.set(mode=source) 274 | 275 | @property 276 | def fan_mode(self) -> str | None: 277 | return str(self._attr_fan_mode) 278 | 279 | @property 280 | def fan_modes(self) -> list[str] | None: 281 | return [str(i) for i in self._attr_fan_modes] 282 | 283 | @classmethod 284 | def attr_fan_modes(cls) -> list[int] | None: 285 | return cls._attr_fan_modes 286 | -------------------------------------------------------------------------------- /custom_components/ha_tion_btle/config_flow.py: -------------------------------------------------------------------------------- 1 | """Adds config flow for Tion custom component.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | import datetime 6 | import asyncio 7 | 8 | import bleak 9 | import tion_btle 10 | import voluptuous as vol 11 | 12 | from homeassistant import config_entries 13 | from homeassistant.components import bluetooth 14 | from homeassistant.config_entries import ConfigEntry 15 | from homeassistant.core import callback, async_get_hass 16 | from tion_btle.tion import Tion 17 | 18 | from .const import DOMAIN, TION_SCHEMA, CONF_MAC 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | TION_OPTIONS_SCHEMA = TION_SCHEMA.copy() 23 | del TION_OPTIONS_SCHEMA["pair"] 24 | del TION_OPTIONS_SCHEMA[CONF_MAC] 25 | del TION_OPTIONS_SCHEMA["model"] 26 | 27 | 28 | class TionFlow: 29 | def __init__(self): 30 | self._data: dict = {} 31 | self._config_entry: ConfigEntry = {} 32 | self._retry: bool = False 33 | 34 | @staticmethod 35 | def __get_my_platform(config: dict): 36 | for i in config: 37 | if i['platform'] == DOMAIN: 38 | return i 39 | 40 | @staticmethod 41 | def __add_default_value(config: dict, key: str) -> dict: 42 | result = {} 43 | if key in config: 44 | if key == 'pair': 45 | # don't suggest pairing if device already configured 46 | result['default'] = False 47 | else: 48 | result['default'] = config[key] 49 | elif 'default' in TION_SCHEMA[key].keys(): 50 | result['default'] = TION_SCHEMA[key]['default'] 51 | 52 | return result 53 | 54 | @staticmethod 55 | def __add_value_from_saved_settings(config: dict, key: str) -> dict: 56 | result = {} 57 | try: 58 | value = config[key].seconds if isinstance(config[key], datetime.timedelta) else config[key] 59 | result['description'] = {"suggested_value": value} 60 | except (TypeError, KeyError): 61 | # TypeError -- config is not dict (have no climate in config, for example) 62 | # KeyError -- config have no key (have climate, but have no Tion) 63 | pass 64 | 65 | return result 66 | 67 | def get_schema(self, schema_description: dict = None) -> vol.Schema: 68 | schema = vol.Schema({}) 69 | if schema_description is None: 70 | schema_description = {} 71 | 72 | for k in schema_description.keys(): 73 | type = vol.Required if TION_SCHEMA[k]['required'] else vol.Optional 74 | options = {} 75 | options.update(self.__add_default_value(self.config, k)) 76 | options.update(self.__add_value_from_saved_settings(self.config, k)) 77 | if self._retry: 78 | options.update(self.__add_value_from_saved_settings(self._data, k)) 79 | schema = schema.extend({type(k, **options): TION_SCHEMA[k]['type']}) 80 | return schema 81 | 82 | @property 83 | def config(self) -> dict: 84 | try: 85 | data = dict(self._config_entry.data or {}) 86 | except AttributeError: 87 | data = {} 88 | 89 | try: 90 | options = self._config_entry.options or {} 91 | data.update(options) 92 | except AttributeError: 93 | pass 94 | return data 95 | 96 | @staticmethod 97 | def getTion(model: str, mac: str) -> tion_btle.TionS3 | tion_btle.TionLite | tion_btle.TionS4: 98 | 99 | btle_device = bluetooth.async_ble_device_from_address(hass=async_get_hass(), address=mac, connectable=True) 100 | if btle_device is None: 101 | message = f"Could not find device with {mac=}" 102 | _LOGGER.critical(f"getTion: {message}") 103 | raise bleak.BleakError(message) 104 | 105 | if model == 'S3': 106 | from tion_btle.s3 import TionS3 as Breezer 107 | elif model == 'S4': 108 | from tion_btle.s4 import TionS4 as Breezer 109 | elif model == 'Lite': 110 | from tion_btle.lite import TionLite as Breezer 111 | else: 112 | raise NotImplementedError("Model '%s' is not supported!" % model) 113 | return Breezer(btle_device) 114 | 115 | 116 | class TionConfigFlow(TionFlow, config_entries.ConfigFlow, domain=DOMAIN): 117 | """Initial setup.""" 118 | VERSION = 1 119 | CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL 120 | 121 | def __init__(self): 122 | super().__init__() 123 | 124 | @staticmethod 125 | @callback 126 | def async_get_options_flow(config_entry): 127 | return TionOptionsFlowHandler(config_entry) 128 | 129 | async def _create_entry(self, data, title, step: str): 130 | if step in ["user", "pair"]: 131 | await self.async_set_unique_id(data["mac"]) 132 | self._abort_if_unique_id_configured() 133 | return self.async_create_entry(title=title, data=data) 134 | 135 | async def async_step_user(self, input=None): 136 | """user initiates a flow via the user interface.""" 137 | 138 | if input is not None: 139 | result = {} 140 | self._data = input 141 | if input['pair']: 142 | _LOGGER.debug("Showing pair info") 143 | return self.async_show_form(step_id="pair") 144 | else: 145 | _LOGGER.debug("Going create entry with name %s" % input['name']) 146 | _LOGGER.debug(input) 147 | try: 148 | _tion: Tion = self.getTion(input['model'], input['mac']) 149 | result = _tion.get() 150 | except Exception as e: 151 | _LOGGER.error("Could not get data from breezer. result is %s, error: %s" % (result, str(e))) 152 | return self.async_show_form(step_id='add_failed') 153 | 154 | return await self._create_entry(title=input['name'], data=input, step="user") 155 | 156 | return self.async_show_form(step_id="user", data_schema=self.get_schema(TION_SCHEMA)) 157 | 158 | async def async_step_pair(self, input): 159 | """Pair host and breezer""" 160 | _LOGGER.debug("Real pairing step") 161 | result = {} 162 | try: 163 | _LOGGER.debug(self._data) 164 | _tion: Tion = self.getTion(self._data['model'], self._data['mac']) 165 | await _tion.pair() 166 | # We should sleep a bit, because immediately connection will cause device disconnected exception while 167 | # enabling notifications 168 | await asyncio.sleep(3) 169 | 170 | result = await _tion.get() 171 | except Exception as e: 172 | _LOGGER.error("Cannot pair and get data. Data is %s, result is %s; %s: %s", self._data, result, 173 | type(e).__name__, str(e)) 174 | return self.async_show_form(step_id='pair_failed') 175 | 176 | return await self._create_entry(title=self._data['name'], data=self._data, step="pair") 177 | 178 | async def async_step_add_failed(self, input): 179 | _LOGGER.debug("Add failed. Returning to first step") 180 | self._retry = True 181 | return await self.async_step_user(None) 182 | 183 | async def async_step_pair_failed(self, input): 184 | _LOGGER.debug("Pair failed. Returning to first step") 185 | self._retry = True 186 | return await self.async_step_user(None) 187 | 188 | 189 | class TionOptionsFlowHandler(TionConfigFlow, config_entries.OptionsFlow): 190 | """Change options dialog.""" 191 | 192 | def __init__(self, config_entry): 193 | """Initialize Shelly options flow.""" 194 | super().__init__() 195 | self._config_entry = config_entry 196 | self._entry_id = config_entry.entry_id 197 | 198 | # config_entry.add_update_listener(update_listener) 199 | 200 | async def async_step_init(self, input=None): 201 | if input is not None: 202 | return await self._create_entry(title="", data=input, step="options") 203 | else: 204 | return self.async_show_form(step_id="init", data_schema=self.get_schema(TION_OPTIONS_SCHEMA)) 205 | -------------------------------------------------------------------------------- /custom_components/ha_tion_btle/const.py: -------------------------------------------------------------------------------- 1 | """Consts for Tion component""" 2 | from homeassistant.components.climate import PLATFORM_SCHEMA 3 | from homeassistant.components.climate.const import ( 4 | ATTR_PRESET_MODE, 5 | PRESET_AWAY, 6 | PRESET_BOOST, 7 | PRESET_SLEEP, 8 | PRESET_NONE, 9 | ) 10 | from homeassistant.const import (ATTR_TEMPERATURE, CONF_NAME, EVENT_HOMEASSISTANT_START, PRECISION_WHOLE, Platform, ) 11 | from voluptuous import All, In 12 | DOMAIN = 'ha_tion_btle' 13 | DEFAULT_NAME = "Tion Breezer" 14 | 15 | CONF_TARGET_TEMP = "target_temp" 16 | CONF_KEEP_ALIVE = "keep_alive" 17 | CONF_INITIAL_HVAC_MODE = "initial_hvac_mode" 18 | CONF_AWAY_TEMP = "away_temp" 19 | CONF_MAC = "mac" 20 | PLATFORMS = [Platform.SENSOR, Platform.CLIMATE, Platform.SELECT, Platform.FAN] 21 | SUPPORTED_DEVICES = ['S3', 'S4', 'Lite'] 22 | 23 | TION_SCHEMA = { 24 | 'model': {'type': All(str, In(SUPPORTED_DEVICES)), 'required': True}, 25 | 'name': {'type': str, 'default': DEFAULT_NAME, 'required': True}, 26 | CONF_MAC: {'type': str, 'required': True}, 27 | CONF_KEEP_ALIVE: {'type': int, 'default': 60, 'required': False}, 28 | CONF_AWAY_TEMP: {'type': int, 'default': 15, 'required': False}, 29 | 'pair': {'type': bool, 'default': True, 'required': False}, 30 | } -------------------------------------------------------------------------------- /custom_components/ha_tion_btle/fan.py: -------------------------------------------------------------------------------- 1 | """ 2 | Fan controls for Tion breezers 3 | """ 4 | from __future__ import annotations 5 | 6 | import logging 7 | from datetime import timedelta 8 | from functools import cached_property 9 | from typing import Any 10 | 11 | from homeassistant.components.climate.const import PRESET_BOOST, PRESET_NONE 12 | from homeassistant.components.fan import FanEntityDescription, FanEntity, DIRECTION_FORWARD, FanEntityFeature 13 | from homeassistant.config_entries import ConfigEntry 14 | from homeassistant.const import Platform 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.helpers.entity import EntityCategory 17 | from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry 18 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 19 | 20 | from . import TionInstance 21 | from .climate import TionClimateEntity 22 | from .const import DOMAIN 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | SCAN_INTERVAL = timedelta(seconds=30) 27 | 28 | config = FanEntityDescription( 29 | key="fan_speed", 30 | name="fan speed", 31 | entity_registry_enabled_default=True, 32 | icon="mdi:fan", 33 | ) 34 | 35 | 36 | async def async_setup_entry(hass: HomeAssistant, _config: ConfigEntry, async_add_entities): 37 | """Set up the sensor entry""" 38 | 39 | async_add_entities([TionFan(config, hass.data[DOMAIN][_config.unique_id], hass)]) 40 | return True 41 | 42 | 43 | class TionFan(FanEntity, CoordinatorEntity): 44 | _attr_supported_features = FanEntityFeature.PRESET_MODE | FanEntityFeature.SET_SPEED 45 | _attr_oscillating = False 46 | _attr_preset_modes = [PRESET_NONE, PRESET_BOOST] 47 | _attr_speed_count = 6 # Must be synced with TionClimateEntity._attr_fan_modes 48 | _attr_current_direction = DIRECTION_FORWARD 49 | _mode_percent_mapping = { 50 | 0: 0, 51 | 1: 17, 52 | 2: 33, 53 | 3: 50, 54 | 4: 67, 55 | 5: 83, 56 | 6: 100, 57 | } 58 | _percent_mode_mapping = { 59 | 0: 0, 60 | 16: 1, 61 | 17: 1, 62 | 33: 2, 63 | 50: 3, 64 | 66: 4, 65 | 67: 4, 66 | 83: 5, 67 | 100: 6, 68 | } 69 | # Home Assistant is using float speed step and ceil to determinate supported speed percents. 70 | 71 | def set_preset_mode(self, preset_mode: str) -> None: 72 | pass 73 | 74 | def set_direction(self, direction: str) -> None: 75 | raise NotImplemented 76 | 77 | def turn_on(self, percentage: int | None = None, preset_mode: str | None = None, **kwargs) -> None: 78 | raise NotImplemented 79 | 80 | def oscillate(self, oscillating: bool) -> None: 81 | raise NotImplemented 82 | 83 | def turn_off(self, **kwargs: Any) -> None: 84 | pass 85 | 86 | def set_percentage(self, percentage: int) -> None: 87 | raise NotImplemented 88 | 89 | def __init__(self, description: FanEntityDescription, instance: TionInstance, hass: HomeAssistant): 90 | """Initialize the fan.""" 91 | 92 | CoordinatorEntity.__init__(self=self, coordinator=instance, ) 93 | self.entity_description = description 94 | self._attr_name = f"{instance.name} {description.name}" 95 | self._attr_device_info = instance.device_info 96 | self._attr_unique_id = f"{instance.unique_id}-{description.key}" 97 | self._saved_fan_mode = None 98 | 99 | _LOGGER.debug(f"Init of fan {self.name} ({instance.unique_id})") 100 | _LOGGER.debug(f"Speed step is {self.percentage_step}") 101 | 102 | registry = async_get_entity_registry(hass=hass) 103 | entity = registry.async_get_or_create( 104 | domain=Platform.FAN, 105 | platform=DOMAIN, 106 | unique_id=self.unique_id, 107 | ) 108 | _LOGGER.debug(f"{entity.entity_category=}, {entity.entity_id=}, {entity.options=} {entity.unique_id=}") 109 | if entity.entity_category == EntityCategory.CONFIG: 110 | import attr # pylint: disable=import-outside-toplevel 111 | 112 | _LOGGER.debug(f"Updating {entity.entity_category=} for {entity.entity_id=}") 113 | new_value = {"entity_category": None} 114 | registry.entities[entity.entity_id] = attr.evolve(registry.entities[entity.entity_id], **new_value) 115 | registry.async_schedule_save() 116 | 117 | def percent2mode(self, percentage: int) -> int: 118 | result = 0 119 | try: 120 | return self._percent_mode_mapping[percentage] 121 | except KeyError: 122 | _LOGGER.warning(f"Could not to convert {percentage} to mode with {self._percent_mode_mapping}. " 123 | f"Will use fall back method.") 124 | for i in range(len(TionClimateEntity.attr_fan_modes())): 125 | if percentage < self.percentage_step * i: 126 | break 127 | else: 128 | result = i 129 | else: 130 | result = 6 131 | 132 | return result 133 | 134 | def mode2percent(self) -> int | None: 135 | return self._mode_percent_mapping[self.fan_mode] if self.fan_mode is not None else None 136 | 137 | async def async_set_percentage(self, percentage: int) -> None: 138 | """Set the speed of the fan, as a percentage.""" 139 | await self.coordinator.set(fan_speed=self.percent2mode(percentage), is_on=percentage > 0) 140 | 141 | @cached_property 142 | def boost_fan_mode(self) -> int: 143 | return max(TionClimateEntity.attr_fan_modes()) 144 | 145 | @property 146 | def fan_mode(self): 147 | return self.coordinator.data.get(self.entity_description.key) 148 | 149 | async def async_set_preset_mode(self, preset_mode: str) -> None: 150 | if preset_mode == PRESET_BOOST and self.preset_mode != PRESET_BOOST: 151 | if self._saved_fan_mode is None: 152 | self._saved_fan_mode = int(self.fan_mode) 153 | 154 | await self.coordinator.set(fan_speed=self.boost_fan_mode) 155 | if preset_mode == PRESET_NONE and self.preset_mode == PRESET_BOOST: 156 | if self._saved_fan_mode is not None: 157 | await self.coordinator.set(fan_speed=self._saved_fan_mode) 158 | self._saved_fan_mode = None 159 | 160 | self._attr_preset_mode = preset_mode 161 | 162 | async def async_turn_on(self, percentage: int | None = None, preset_mode: str | None = None, **kwargs, ) -> None: 163 | target_speed = 2 if self._saved_fan_mode is None else self._saved_fan_mode 164 | self._saved_fan_mode = None 165 | await self.coordinator.set(fan_speed=target_speed, is_on=True) 166 | 167 | async def async_turn_off(self, **kwargs: Any) -> None: 168 | if self._saved_fan_mode is None and self.fan_mode > 0: 169 | self._saved_fan_mode = self.fan_mode 170 | 171 | await self.coordinator.set(is_on=False) 172 | 173 | def _handle_coordinator_update(self) -> None: 174 | self._attr_assumed_state = False if self.coordinator.last_update_success else True 175 | self._attr_is_on = self.coordinator.data.get("is_on") 176 | self._attr_percentage = self.mode2percent() if self._attr_is_on else 0 # should check attr to avoid deadlock 177 | self.async_write_ha_state() 178 | 179 | @property 180 | def available(self) -> bool: 181 | """Return if entity is available.""" 182 | return True 183 | -------------------------------------------------------------------------------- /custom_components/ha_tion_btle/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "ha_tion_btle", 3 | "name": "Tion breezer", 4 | "documentation": "https://github.com/TionAPI/HA-tion/wiki", 5 | "dependencies": [ 6 | "bluetooth", 7 | "fan" 8 | ], 9 | "requirements": [ 10 | "tion-btle==3.3.6" 11 | ], 12 | "codeowners": [ 13 | "@IATkachenko" 14 | ], 15 | "config_flow": true, 16 | "version": "4.1.9" 17 | } 18 | -------------------------------------------------------------------------------- /custom_components/ha_tion_btle/manifest.json.tpl: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "ha_tion_btle", 3 | "name": "Tion breezer", 4 | "documentation": "https://github.com/TionAPI/HA-tion/wiki", 5 | "dependencies": [ 6 | "bluetooth", 7 | "fan" 8 | ], 9 | "requirements": [ 10 | "tion-btle==3.3.6" 11 | ], 12 | "codeowners": [ 13 | "@IATkachenko" 14 | ], 15 | "config_flow": true, 16 | "version": "%%%VERSION%%%" 17 | } 18 | -------------------------------------------------------------------------------- /custom_components/ha_tion_btle/select.py: -------------------------------------------------------------------------------- 1 | from homeassistant.components.select import SelectEntityDescription, SelectEntity 2 | from homeassistant.config_entries import ConfigEntry 3 | from homeassistant.core import HomeAssistant 4 | from homeassistant.helpers.entity import EntityCategory 5 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 6 | 7 | from . import TionInstance 8 | from .const import DOMAIN 9 | 10 | INPUT_SELECTS: tuple[SelectEntityDescription, ...] = ( 11 | SelectEntityDescription( 12 | key="mode", 13 | name="Air mode", 14 | icon="mdi:air-filter", 15 | entity_registry_enabled_default=True, 16 | entity_category=EntityCategory.CONFIG, 17 | ), 18 | ) 19 | 20 | 21 | async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry, async_add_entities): 22 | """Set up the sensor entry""" 23 | tion_instance = hass.data[DOMAIN][config.unique_id] 24 | entities: list[TionInputSelect] = [ 25 | TionInputSelect(description, tion_instance, hass) for description in INPUT_SELECTS] 26 | async_add_entities(entities) 27 | 28 | return True 29 | 30 | 31 | class TionInputSelect(SelectEntity, CoordinatorEntity): 32 | coordinator: TionInstance 33 | 34 | def select_option(self, option: str) -> None: 35 | pass 36 | 37 | async def async_select_option(self, option: str) -> None: 38 | await self.coordinator.set(mode=option) 39 | self._handle_coordinator_update() 40 | 41 | def __init__(self, description: SelectEntityDescription, instance: TionInstance, hass: HomeAssistant): 42 | CoordinatorEntity.__init__(self=self, coordinator=instance, ) 43 | self.hass = hass 44 | 45 | self.entity_description = description 46 | self._attr_name = f"{instance.name} {description.name}" 47 | self._attr_device_info = instance.device_info 48 | self._attr_unique_id = f"{instance.unique_id}-{description.key}" 49 | self._attr_icon = self.entity_description.icon 50 | self._attr_entity_registry_enabled_default = self.entity_description.entity_registry_enabled_default 51 | self._attr_entity_category = self.entity_description.entity_category 52 | 53 | self._attr_options = self.coordinator.supported_air_sources 54 | self._attr_current_option = self.coordinator.data.get(self.entity_description.key) 55 | 56 | def _handle_coordinator_update(self) -> None: 57 | self._attr_current_option = self.coordinator.data.get(self.entity_description.key) 58 | self._attr_assumed_state = False if self.coordinator.last_update_success else True 59 | self.async_write_ha_state() 60 | 61 | @property 62 | def available(self) -> bool: 63 | """Return if entity is available.""" 64 | return True 65 | -------------------------------------------------------------------------------- /custom_components/ha_tion_btle/sensor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sensors for Tion breezers 3 | """ 4 | import logging 5 | from datetime import timedelta 6 | 7 | from homeassistant.components.sensor import SensorEntityDescription, SensorDeviceClass, SensorStateClass, SensorEntity 8 | from homeassistant.const import UnitOfTemperature 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.helpers.entity import EntityCategory 12 | from homeassistant.helpers.update_coordinator import CoordinatorEntity 13 | 14 | from . import TionInstance 15 | from .const import DOMAIN 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | SCAN_INTERVAL = timedelta(seconds=30) 20 | 21 | SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( 22 | SensorEntityDescription( 23 | key="in_temp", 24 | name="input temperature", 25 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 26 | device_class=SensorDeviceClass.TEMPERATURE, 27 | state_class=SensorStateClass.MEASUREMENT, 28 | entity_registry_enabled_default=True, 29 | icon="mdi:import", 30 | ), 31 | SensorEntityDescription( 32 | key="out_temp", 33 | name="output temperature", 34 | native_unit_of_measurement=UnitOfTemperature.CELSIUS, 35 | device_class=SensorDeviceClass.TEMPERATURE, 36 | state_class=SensorStateClass.MEASUREMENT, 37 | entity_registry_enabled_default=True, 38 | icon="mdi:export", 39 | ), 40 | SensorEntityDescription( 41 | key="filter_remain", 42 | name="filters remain", 43 | entity_registry_enabled_default=True, 44 | entity_category=EntityCategory.DIAGNOSTIC, 45 | ), 46 | 47 | SensorEntityDescription( 48 | key="fan_speed", 49 | name="current fan speed", 50 | entity_registry_enabled_default=True, 51 | state_class=SensorStateClass.MEASUREMENT, 52 | icon="mdi:fan", 53 | ), 54 | SensorEntityDescription( 55 | key="rssi", 56 | name="rssi", 57 | entity_registry_enabled_default=False, 58 | state_class=SensorStateClass.MEASUREMENT, 59 | entity_category=EntityCategory.DIAGNOSTIC, 60 | icon="mdi:access-point", 61 | ), 62 | ) 63 | 64 | 65 | async def async_setup_platform(_hass: HomeAssistant, _config, _async_add_entities, _discovery_info=None): 66 | _LOGGER.critical("Sensors configuration via configuration.yaml is not supported!") 67 | return False 68 | 69 | 70 | async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry, async_add_entities): 71 | """Set up the sensor entry""" 72 | tion_instance = hass.data[DOMAIN][config.unique_id] 73 | entities: list[TionSensor] = [ 74 | TionSensor(description, tion_instance) for description in SENSOR_TYPES] 75 | async_add_entities(entities) 76 | 77 | return True 78 | 79 | 80 | class TionSensor(SensorEntity, CoordinatorEntity): 81 | """Representation of a sensor.""" 82 | 83 | def __init__(self, description: SensorEntityDescription, instance: TionInstance): 84 | """Initialize the sensor.""" 85 | 86 | CoordinatorEntity.__init__( 87 | self=self, 88 | coordinator=instance, 89 | ) 90 | self.entity_description = description 91 | self._attr_name = f"{instance.name} {description.name}" 92 | self._attr_device_info = instance.device_info 93 | self._attr_unique_id = f"{instance.unique_id}-{description.key}" 94 | 95 | _LOGGER.debug(f"Init of sensor {self.name} ({instance.unique_id})") 96 | 97 | @property 98 | def native_value(self): 99 | """Return the state of the sensor.""" 100 | value = self.coordinator.data.get(self.entity_description.key) 101 | 102 | if self.entity_description.key == "fan_speed": 103 | if not self.coordinator.data.get("is_on"): 104 | # return zero fan speed if breezer turned off 105 | value = 0 106 | 107 | return value 108 | 109 | def _handle_coordinator_update(self) -> None: 110 | self._attr_assumed_state = False if self.coordinator.last_update_success else True 111 | self.async_write_ha_state() 112 | 113 | @property 114 | def available(self) -> bool: 115 | """Return if entity is available.""" 116 | return True 117 | -------------------------------------------------------------------------------- /custom_components/ha_tion_btle/services.yaml: -------------------------------------------------------------------------------- 1 | set_air_source: 2 | name: Air source 3 | description: Set air source 4 | target: 5 | device: 6 | integration: ha_tion_btle 7 | fields: 8 | source: 9 | name: Air source 10 | description: "Where breezer should get air. Not all variants may be supported by breezer" 11 | example: recirculation 12 | required: true 13 | selector: 14 | select: 15 | options: 16 | - "outside" 17 | - "recirculation" 18 | - "mixed" -------------------------------------------------------------------------------- /custom_components/ha_tion_btle/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Tion integration", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "title": "Configuration for Tion breezer", 7 | "data": { 8 | "model": "Device model", 9 | "mac": "Device MAC address", 10 | "name": "Name for device", 11 | "away_temp": "Temperature (celsius) for AWAY mode", 12 | "keep_alive": "Interval for querying breezer", 13 | "pair": "Need device pairing?" 14 | } 15 | }, 16 | "pair": { 17 | "title": "Device pairing", 18 | "description": "Turn breezer to pair mode by holding button for more than 5 seconds for pairing with Home Assistant.\n\nMake sure that there is no devices connected to breezer while adding it to Home Assistant.\n\n![Location of button on bridge](/static/images/config_philips_hue.jpg)" 19 | }, 20 | "pair_failed": { 21 | "title": "Could not pair!", 22 | "description": "Error occurs while pairing routine! Check logs for details.\n\nMake sure that there is no devices connected to breezer while adding it to Home Assistant." 23 | }, 24 | "add_failed": { 25 | "title": "Could not get test data from breezer!", 26 | "description": "Error occurs while getting data. May be you need pairing. Check logs for details.\n\nMake sure that there is no devices connected to breezer while adding it to Home Assistant." 27 | } 28 | } 29 | }, 30 | "options": { 31 | "step": { 32 | "init": { 33 | "title": "Configuration for Tion breezer", 34 | "data": { 35 | "name": "Name for device", 36 | "away_temp": "Temperature (celsius) for AWAY mode", 37 | "keep_alive": "Interval for querying breezer" 38 | } 39 | } 40 | } 41 | } 42 | 43 | } -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Tion breezer", 3 | "country": "RU", 4 | "render_readme": true, 5 | "zip_release": true, 6 | "filename": "ha_tion_btle.zip", 7 | "homeassistant": "2022.8.0" 8 | } -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | # Tion breezer component 2 | This component will allow your Home assistant to control flowing parameters of **Tion S3** breezers: 3 | * fan speed 4 | * target heater temp 5 | * heater mode (on/off) 6 | * presets: 7 | * boost 8 | * away 9 | 10 | Breezer will be controlled via bluetooth and available as climate device in Home assistant. 11 | 12 | For more details please read [README](https://github.com/TionAPI/HA-tion/blob/master/README.md). 13 | --------------------------------------------------------------------------------