├── .dockerignore ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── new-device-support-request.md └── workflows │ ├── docker_build.yml │ └── pythonapp.yml ├── .gitignore ├── .python-version ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── bt-mqtt-gateway.service ├── config.py ├── config.yaml.example ├── const.py ├── docker-compose.yml ├── exceptions.py ├── gateway.py ├── hooks ├── post_push └── pre_build ├── logger.py ├── logger.yaml ├── mqtt.py ├── multi-arch-manifest.yaml ├── requirements.txt ├── service.sh ├── start.sh ├── utils.py ├── workers ├── am43.py ├── base.py ├── blescanmulti.py ├── ibbq.py ├── lightstring.py ├── linakdesk.py ├── lywsd02.py ├── lywsd03mmc.py ├── lywsd03mmc_homeassistant.py ├── miflora.py ├── miscale.py ├── mithermometer.py ├── mysensors.py ├── ruuvitag.py ├── smartgadget.py ├── switchbot.py ├── thermostat.py ├── toothbrush.py └── toothbrush_homeassistant.py ├── workers_manager.py ├── workers_queue.py └── workers_requirements.py /.dockerignore: -------------------------------------------------------------------------------- 1 | /config.yaml 2 | Dockerfile* 3 | .git 4 | .idea 5 | .venv 6 | .github 7 | __pycache__ 8 | __pycache__/* 9 | -------------------------------------------------------------------------------- /.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 | [] I've read the [Troubleshooting Wiki](https://github.com/zewelor/bt-mqtt-gateway/wiki/Troubleshooting), my problem is not described there and I am already using the specified minimum bluez version. 11 | 12 | **Describe the bug** 13 | A clear and concise description of what the bug is. 14 | 15 | **To Reproduce** 16 | Steps to reproduce the behavior: 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | **Expected behavior** 23 | A clear and concise description of what you expected to happen. 24 | 25 | **Config** 26 | Used config 27 | 28 | **Debug gateway logs** 29 | run gateway.py with -d switch and paste formatted output log 30 | 31 | **Server (please complete the following information):** 32 | - OS: [e.g. Linux] 33 | - Distro: [e.g. Raspbian] 34 | - Version [e.g. November 2018] 35 | 36 | **Additional context** 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new-device-support-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: New device support request 3 | about: Request to support new device 4 | title: '' 5 | labels: new device 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Device name / url** 11 | A clear and concise description of what the device is and any references. Ex. Make, model, revision, description, url [...] 12 | 13 | **Link to some sample code / lib that supports this device** 14 | If another project supports your requested device, please provide a link or sample code. 15 | 16 | **Functions and features** 17 | A clear and concise description of what you want to happen. 18 | 19 | **Checklist** 20 | - [] I understand that supporting a device, that no one owns or uses, may required a long wait time, or might never end up being supported. 21 | -------------------------------------------------------------------------------- /.github/workflows/docker_build.yml: -------------------------------------------------------------------------------- 1 | name: Build and push 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | push: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test_build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: checkout code 14 | uses: actions/checkout@v2 15 | - name: Set up QEMU 16 | uses: docker/setup-qemu-action@v1 17 | - name: Set up Docker Buildx 18 | uses: docker/setup-buildx-action@v1 19 | - name: Build 20 | id: docker_build 21 | uses: docker/build-push-action@v2 22 | with: 23 | platforms: linux/amd64,linux/arm64, linux/arm/v7, linux/arm/v6 24 | cache-from: type=gha 25 | cache-to: type=gha,mode=max 26 | 27 | build_and_push: 28 | runs-on: ubuntu-latest 29 | if: github.ref == 'refs/heads/master' 30 | needs: test_build 31 | steps: 32 | - name: checkout code 33 | uses: actions/checkout@v2 34 | - name: Set up QEMU 35 | uses: docker/setup-qemu-action@v1 36 | - name: Set up Docker Buildx 37 | uses: docker/setup-buildx-action@v1 38 | - name: Login to GitHub Container Registry 39 | uses: docker/login-action@v1 40 | with: 41 | username: ${{ secrets.DOCKER_USERNAME }} 42 | password: ${{ secrets.DOCKER_PASSWORD }} 43 | - name: Build and push 44 | id: docker_build 45 | uses: docker/build-push-action@v2 46 | with: 47 | push: true 48 | platforms: linux/amd64,linux/arm64, linux/arm/v7, linux/arm/v6 49 | labels: | 50 | org.opencontainers.image.source=https://github.com/${{ github.repository }} 51 | org.opencontainers.image.revision=${{ github.sha }} 52 | tags: | 53 | ${{ secrets.DOCKER_USERNAME }}/bt-mqtt-gateway:${{ github.sha }} 54 | ${{ secrets.DOCKER_USERNAME }}/bt-mqtt-gateway:latest 55 | cache-from: type=gha 56 | cache-to: type=gha,mode=max 57 | -------------------------------------------------------------------------------- /.github/workflows/pythonapp.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python application 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ["3.7", "3.8", "3.9", "3.10"] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v1 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install -r requirements.txt 30 | - name: Lint with flake8 31 | run: | 32 | pip install flake8 33 | # stop the build if there are Python syntax errors or undefined names 34 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 35 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 36 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.yaml 2 | __pycache__/ 3 | .idea/ 4 | .venv 5 | .vscode/ 6 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.7.2/envs/bt-mqtt-gateway 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at zewelor@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # How to contribute 3 | 4 | We are really glad you're reading this, because we need volunteer developers to help this project come to fruition. 5 | 6 | ## Submitting changes 7 | 8 | Please send a [GitHub Pull Request to bt-mqtt-gateway](https://github.com/zewelor/bt-mqtt-gateway/pull/new/master) with a clear list of what you've done (read more about [pull requests](http://help.github.com/pull-requests/)). When you send a pull request, we will love you forever if you update the README or Wiki to reflect your changes. Please follow our coding conventions (below) and make sure all of your commits are atomic (one feature per commit). 9 | 10 | Always write a clear log message for your commits. One-line messages are fine for small changes, but bigger changes should look like this: 11 | 12 | $ git commit -m "A brief summary of the commit 13 | > 14 | > A paragraph describing what changed and its impact." 15 | 16 | ## Coding conventions 17 | 18 | Start reading our code and you'll get the hang of it. We optimize for readability: 19 | 20 | * We indent using two spaces (soft tabs) 21 | * This is open source software. Consider the people who will read your code, and make it look nice for them. It's sort of like driving a car: Perhaps you love doing donuts when you're alone, but with passengers the goal is to make the ride as smooth as possible. 22 | 23 | Thanks! 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-alpine 2 | 3 | ENV DEBUG false 4 | 5 | RUN mkdir /application 6 | COPY . /application 7 | 8 | WORKDIR /application 9 | 10 | RUN apk add --no-cache tzdata bluez bluez-libs sudo bluez-deprecated && \ 11 | apk add --no-cache --virtual build-dependencies git bluez-dev musl-dev make gcc glib-dev musl-dev && \ 12 | pip install --no-cache-dir -r requirements.txt && \ 13 | pip install --no-cache-dir `./gateway.py -r all` && \ 14 | apk del build-dependencies 15 | 16 | COPY ./start.sh /start.sh 17 | RUN chmod +x /start.sh 18 | 19 | ENTRYPOINT ["/bin/sh", "-c", "/start.sh"] 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 zewelor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | # Description 3 | 4 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. 5 | 6 | Fixes # (issue) 7 | 8 | ## Type of change 9 | 10 | Please delete options that are not relevant. 11 | 12 | - [ ] Bug fix (non-breaking change which fixes an issue) 13 | - [ ] New feature (non-breaking change which adds functionality) 14 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 15 | - [ ] This change requires a documentation update 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | I suggest to use https://esphome.io/components/bluetooth_proxy.html instead. In my opinion its better solution now. 2 | 3 | 4 | # bt-mqtt-gateway 5 | 6 | A simple Python script which provides a Bluetooth to MQTT gateway, easily extensible via custom workers. 7 | See [Wiki](https://github.com/zewelor/bt-mqtt-gateway/wiki) for more information. 8 | 9 | ## Features 10 | 11 | * Highly extensible via custom workers 12 | * Data publication via MQTT 13 | * Configurable topic and payload 14 | * MQTT authentication support 15 | * Systemd service 16 | * Reliable and intuitive 17 | * Tested on Raspberry Pi Zero W 18 | 19 | ### Supported devices 20 | 21 | * [EQ3 Bluetooth smart thermostat](http://www.eq-3.com/products/eqiva/bluetooth-smart-radiator-thermostat.html) via [python-eq3bt](https://github.com/rytilahti/python-eq3bt) 22 | * [Xiaomi Mi Scale](http://www.mi.com/en/scale/) 23 | * [Xiaomi Mi Scale v2 (Body Composition Scale)](https://www.mi.com/global/mi-body-composition-scale) 24 | * [Linak Desk](https://www.linak.com/business-areas/desks/office-desks/) via [linak_bt_desk](https://github.com/zewelor/linak_bt_desk) 25 | * [MySensors](https://www.mysensors.org/) 26 | * [Xiaomi Mi Flora plant sensor](https://xiaomi-mi.com/sockets-and-sensors/xiaomi-huahuacaocao-flower-care-smart-monitor/) via [miflora](https://github.com/open-homeautomation/miflora) 27 | * Xiaomi Aqara thermometer via [mithermometer](https://github.com/hobbypunk90/mithermometer) 28 | * Bluetooth Low Power devices (BLE) 29 | * [Oral-B connected toothbrushes](https://oralb.com/en-us/products#viewtype:gridview/facets:feature=feature-bluetooth-connectivity/category:products/page:0/sortby:Featured-Sort/productsdisplayed:undefined/promotilesenabled:undefined/cwidth:3/pscroll:) 30 | * [Switchbot](https://www.switch-bot.com/) 31 | * [Sensirion SmartGadget](https://www.sensirion.com/en/environmental-sensors/humidity-sensors/development-kit/) via [python-smartgadget](https://github.com/merll/python-smartgadget) 32 | * [RuuviTag](https://ruuvi.com/ruuvitag-specs/) via [ruuvitag-sensor](https://github.com/ttu/ruuvitag-sensor) 33 | * Xiaomi Mijia 2nd gen, aka LYWSD02 34 | 35 | ## Getting Started 36 | 37 | These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. See deployment for notes on how to deploy the project on a live system. 38 | 39 | ### Requirements 40 | - Python 3 installed 41 | - Working bluetooth adapter ( might be built in like in raspberry pi ) 42 | - Working mqtt server, to which you can connect 43 | 44 | **Testing mqtt:** 45 | Use mosquitto_sub to print all messages. Change localhost to your mqtt server address. 46 | ``` 47 | # also helpful for testing MQTT messages 48 | mosquitto_sub -h localhost -d -t '#' 49 | 50 | # if user/password are defined on your mosquitto server, use 51 | mosquitto_sub -h localhost -d -t '#' -u user -P password 52 | 53 | # for more info, see mosquitto_sub --help 54 | ``` 55 | 56 | 57 | ## Installation 58 | 59 | ### Docker 60 | There are prebuilt docker images at https://hub.docker.com/r/zewelor/bt-mqtt-gateway/tags. 61 | Thanks @hobbypunk90 and @krasnoukhov for docker work. 62 | 63 | Mount config.yaml as /application/config.yaml volume 64 | 65 | Example exec 66 | 67 | ```shell 68 | docker run -d --name bt-mqtt-gateway --network=host --cap-add=NET_ADMIN --cap-add=NET_RAW -v $PWD/config.yaml:/application/config.yaml zewelor/bt-mqtt-gateway 69 | ``` 70 | 71 | #### Docker-compose 72 | 73 | See docker-compose.yml file in repo. To run: 74 | 75 | ```shell 76 | docker-compose run bt-mqtt-gateway 77 | ``` 78 | 79 | ### Virtualenv 80 | On a modern Linux system, just a few steps are needed to get the gateway working. 81 | The following example shows the installation under Debian/Raspbian: 82 | 83 | ```shell 84 | sudo apt-get install git python3 python3-pip python3-wheel bluetooth bluez libglib2.0-dev 85 | sudo pip3 install virtualenv 86 | git clone https://github.com/zewelor/bt-mqtt-gateway.git 87 | cd bt-mqtt-gateway 88 | virtualenv -p python3 .venv 89 | source .venv/bin/activate 90 | pip3 install -r requirements.txt 91 | ``` 92 | 93 | All needed python libs, per each worker, should be auto installed on run. If not you can install them manually: 94 | 95 | ```shell 96 | pip3 install `./gateway.py -r configured` 97 | ``` 98 | 99 | ## Configuration 100 | 101 | All worker configuration is done in the file [`config.yaml`](config.yaml.example). 102 | Be sure to change all options for your needs. 103 | This file needs to be created first: 104 | 105 | ```shell 106 | cp config.yaml.example config.yaml 107 | nano config.yaml 108 | source .venv/bin/activate 109 | sudo ./gateway.py 110 | ``` 111 | 112 | **Attention:** 113 | You need to add at least one worker to your configuration. 114 | Scan for available Bluetooth devices in your proximity with the command: 115 | 116 | ```shell 117 | sudo hcitool lescan 118 | ``` 119 | 120 | ## Execution 121 | 122 | A test run is as easy as: 123 | 124 | ```shell 125 | source .venv/bin/activate 126 | sudo ./gateway.py 127 | ``` 128 | 129 | Debug output can be displayed using the `-d` argument: 130 | 131 | ```shell 132 | sudo ./gateway.py -d 133 | ``` 134 | 135 | ## Deployment 136 | 137 | Continuous background execution can be done using the example Systemd service unit provided. 138 | 139 | ```shell 140 | sudo cp bt-mqtt-gateway.service /etc/systemd/system/ 141 | sudo nano /etc/systemd/system/bt-mqtt-gateway.service (modify path of bt-mqtt-gateway) 142 | sudo systemctl daemon-reload 143 | sudo systemctl start bt-mqtt-gateway 144 | sudo systemctl status bt-mqtt-gateway 145 | sudo systemctl enable bt-mqtt-gateway 146 | ``` 147 | 148 | **Attention:** 149 | You need to define the absolute path of `service.sh` in `bt-mqtt-gateway.service`. 150 | 151 | **Dynamically Changing the Update Interval** 152 | To dynamically change the `update_interval` of a worker, publish a message containing the new interval in seconds at the `update_interval` topic. Note that the `update_interval` will revert back to the value in `config.yaml` when the gateway is restarted. 153 | I.E: 154 | ``` 155 | # Set a new update interval of 3 minutes 156 | mosquitto_pub -h localhost -t 'miflora/update_interval' -m '150' 157 | # Set a new update interval of 30 seconds 158 | mosquitto_pub -h localhost -t 'mithermometer/update_interval' -m '30' 159 | ``` 160 | 161 | ## Custom worker development 162 | 163 | Create custom worker in workers [directory](https://github.com/zewelor/bt-mqtt-gateway/tree/master/workers). 164 | 165 | ### Example simple worker 166 | 167 | ```python 168 | from mqtt import MqttMessage 169 | from workers.base import BaseWorker 170 | 171 | REQUIREMENTS = ['pip_packages'] 172 | 173 | class TimeWorker(BaseWorker): 174 | def _setup(self): 175 | self._some = 'variable' 176 | 177 | def status_update(self): 178 | from datetime import datetime 179 | 180 | return [MqttMessage(topic=self.format_topic('time'), payload=datetime.now())] 181 | ``` 182 | 183 | `REQUIREMENTS` add required pip packages, they will be installed on first run. Remember to import them in method, not on top of the file, because on initialization, that package won't exists. Unless installed outside of the gateway. Check status_update method 184 | 185 | `_setup` method - add / declare needed variables. 186 | 187 | `status_update` method - It will be called using specified update_interval 188 | 189 | ### Example config entry 190 | 191 | Add config to the example [config](https://github.com/zewelor/bt-mqtt-gateway/blob/master/config.yaml.example): 192 | 193 | ```yaml 194 | timeworker: 195 | args: 196 | topic_prefix: cool_time_worker 197 | update_interval: 1800 198 | ``` 199 | 200 | Variables set in args section will be set as object attributes in [BaseWorker.__init__](https://github.com/zewelor/bt-mqtt-gateway/blob/master/workers/base.py#L2) 201 | 202 | topic_prefix, if specified, will be added to each mqtt message. Alongside with global_prefix set for gateway 203 | 204 | ## Troubleshooting 205 | [See the Troubleshooting Wiki](https://github.com/zewelor/bt-mqtt-gateway/wiki/Troubleshooting) 206 | 207 | ## Built With 208 | 209 | * [Python](https://www.python.org/) - The high-level programming language for general-purpose programming 210 | 211 | ## Authors 212 | 213 | * [**zewelor**](https://github.com/zewelor) - *Initial work* 214 | * [**bbbenji**](https://github.com/bbbenji) - *Minor contributions* 215 | * [**elviosebastianelli**](https://github.com/elviosebastianelli) - *BLEscanmulti* 216 | * [**jumping2000**](https://github.com/jumping2000) - *BLEscan* 217 | * [**AS137430**](https://github.com/AS137430) - *Switchbot* 218 | 219 | 220 | ## License 221 | 222 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details 223 | -------------------------------------------------------------------------------- /bt-mqtt-gateway.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Bluetooth MQTT gateway 3 | Documentation=https://github.com/zewelor/bt-mqtt-gateway 4 | After=network.target 5 | 6 | [Service] 7 | Type=simple 8 | WorkingDirectory=/home/user/bt-mqtt-gateway 9 | ExecStart=/home/user/bt-mqtt-gateway/service.sh 10 | Restart=always 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | import os 3 | 4 | with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), "config.yaml"), "r") as f: 5 | settings = yaml.safe_load(f) 6 | -------------------------------------------------------------------------------- /config.yaml.example: -------------------------------------------------------------------------------- 1 | mqtt: 2 | host: 192.168.1.1 3 | port: 1883 4 | username: user 5 | password: password 6 | #ca_cert: /etc/ssl/certs/ca-certificates.crt # Uncomment to enable MQTT TLS, update path to appropriate location. 7 | #ca_verify: False # Verify TLS certificate chain and host, disable for testing with self-signed certificates, default to True 8 | #client_cert: mosq_client.crt # If client_cert and client_key are specified, MQTT uses client certificate authentication instead of username + password 9 | #client_key: mosq_client.key 10 | topic_prefix: hostname # All messages will have that prefix added, remove if you dont need this. 11 | client_id: bt-mqtt-gateway 12 | availability_topic: lwt_topic 13 | 14 | manager: 15 | sensor_config: 16 | topic: homeassistant 17 | retain: true 18 | topic_subscription: 19 | update_all: 20 | topic: homeassistant/status 21 | payload: online 22 | command_timeout: 35 # Timeout for worker operations. Can be removed if the default of 35 seconds is sufficient. 23 | command_retries: 0 # Number of retries for worker commands. Default is 0. Might not be supported for all workers. 24 | update_retries: 0 # Number of retries for worker updates. Default is 0. Might not be supported for all workers. 25 | workers: 26 | # mysensors: 27 | # command_timeout: 35 # Optional override of globally set command_timeout. 28 | # command_retries: 0 # Optional override of globally set command_retries. 29 | # update_retries: 0 # Optional override of globally set update_retries. 30 | # args: 31 | # port: /dev/ttyUSB0 32 | # baudrate: 9600 33 | # topic_prefix: mysensors/out 34 | # thermostat: 35 | # args: 36 | # devices: 37 | # bedroom: 00:11:22:33:44:55 # Simple format 38 | # living_room: # Extended format with additional configuration 39 | # mac: 00:11:22:33:44:55 40 | # discovery_temperature_topic: some/sensor/with/temperature # Optional current_temperature_topic for HASS discovery 41 | # discovery_temperature_template: "{{ value_json.temperature }}" # Optional current_temperature_template for HASS discovery 42 | # interface: 0 # Optional interface id for bt communication 43 | # topic_prefix: thermostat 44 | # topic_subscription: thermostat/+/+/set 45 | # update_interval: 60 46 | # miscale: 47 | # args: 48 | # mac: 00:11:22:33:44:55 49 | # topic_prefix: miscale 50 | # users: # Used for recognizing multiple people, as well as for decoding body metrics! (Optional) 51 | # Alex: # Name (used in MQTT to define a user topic) 52 | # weight_template: # The weight template that the user will be tracked by! 53 | # min: 70 54 | # max: 90 55 | # sex: male # Sex (male or female) 56 | # height: 185 # Height (in cm) 57 | # dob: 2000-01-11 # DOB (in yyyy-mm-dd format) 58 | # Olivia: 59 | # weight_template: 60 | # min: 30 61 | # max: 60 62 | # sex: female 63 | # height: 165 64 | # dob: 2000-02-22 65 | # update_interval: 1800 66 | # linakdesk: 67 | # args: 68 | # mac: 00:11:22:33:44:55 69 | # topic_prefix: linak_desk 70 | # update_interval: 1800 71 | # miflora: 72 | # args: 73 | # adapter: hci0 74 | # devices: 75 | # herbs: 00:11:22:33:44:55 76 | # topic_prefix: miflora 77 | # per_device_timeout: 6 # Optional override of globally set per_device_timeout. 78 | # update_interval: 300 79 | # mithermometer: 80 | # args: 81 | # devices: 82 | # living_room: 00:11:22:33:44:55 83 | # topic_prefix: mithermometer 84 | # per_device_timeout: 6 # Optional override of globally set per_device_timeout. 85 | # update_interval: 300 86 | # blescanmulti: 87 | # args: 88 | # devices: 89 | # beacon: 00:11:22:33:44:55 90 | # smartwath: 00:11:22:33:44:55 91 | # topic_prefix: blescan 92 | # available_payload: home 93 | # unavailable_payload: not_home 94 | # available_timeout: 0 95 | # unavailable_timeout: 60 96 | # scan_timeout: 10 97 | # scan_passive: true 98 | # update_interval: 60 99 | # toothbrush: 100 | # args: 101 | # devices: 102 | # ix: 00:11:22:33:44:55 103 | # ia: 11:22:33:44:55:66 104 | # topic_prefix: toothbrush 105 | # update_interval: 10 106 | # toothbrush_homeassistant: 107 | # args: 108 | # autodiscovery_prefix: homeassistant 109 | # topic_prefix: toothbrush 110 | # devices: 111 | # ix: 112 | # name: IX 113 | # mac: 00:11:22:33:44:55 114 | # ia: 115 | # name: IA 116 | # mac: 11:22:33:44:55:66 117 | # update_interval: 10 118 | # switchbot: 119 | # args: 120 | # devices: 121 | # heater: 00:11:22:33:44:55 122 | # topic_prefix: switchbot/bathroom 123 | # state_topic_prefix: switchbot/bathroom 124 | # topic_subscription: switchbot/+/+/set 125 | # update_interval: 60 126 | # smartgadget: 127 | # args: 128 | # devices: 129 | # living_room: 00:11:22:33:44:55 130 | # topic_prefix: smartgadget 131 | # update_interval: 300 132 | # ruuvitag: 133 | # args: 134 | # devices: 135 | # basement: 00:11:22:33:44:55 136 | # topic_prefix: ruuvitag 137 | # update_interval: 60 138 | # lywsd02: 139 | # args: 140 | # devices: 141 | # living_room: 00:11:22:33:44:55 142 | # topic_prefix: mijasensor 143 | # update_interval: 120 144 | # lywsd03mmc: 145 | # args: 146 | # devices: 147 | # bathroom: 00:11:22:33:44:55 148 | # topic_prefix: mijasensor_gen2 149 | # passive: false # Set to true for sensors running custom firmware and advertising type custom. See https://github.com/zewelor/bt-mqtt-gateway/wiki/Devices#lywsd03mmc 150 | # command_timeout: 30 # Optional timeout for getting data for non-passive readouts 151 | # scan_timeout: 20 # Optional timeout for passive scanning 152 | 153 | # update_interval: 120 154 | # lywsd03mmc_homeassistant: 155 | # args: 156 | # devices: 157 | # bathroom: 00:11:22:33:44:55 158 | # topic_prefix: mijasensor_gen2 159 | # update_interval: 120 160 | # am43: 161 | # args: 162 | # devices: 163 | # upper_hall: 164 | # mac: 00:11:22:33:44:55 165 | # pin: 8888 # Pin code for the device 166 | # invert: true # Set to true to make position 100 be open instead of the default of closed 167 | # topic_prefix: blinds 168 | # per_device_timeout: 40 169 | # hass_device_class: blind # Optional; the default will be "shade", see https://www.home-assistant.io/integrations/cover/#device-class for details 170 | # iface: 0 # Optional; you can get the list of available interfaces by calling `hciconfig` 171 | # rapid_update_interval: 10 # Optional; if set — the status of the device would be requested automatically after this value of seconds, 172 | # # if any activity was detected during the last update (or a command was executed) 173 | # default_update_interval: 300 # Optional; used together with `rapid_update_interval`, should have the same value as update_interval. 174 | # # when no changes detected after an update request, and `rapid_update_interval` is set, the update update_interval 175 | # # will be changed to `default_update_interval`. 176 | # topic_subscription: blinds/+/+/+ 177 | # update_interval: 300 178 | # lightstring: 179 | # args: 180 | # devices: 181 | # led_outdoor: 00:11:22:33:44:55 182 | # topic_prefix: lightstring/xmas 183 | # topic_subscription: lightstring/+/+/set 184 | # update_interval: 60 185 | -------------------------------------------------------------------------------- /const.py: -------------------------------------------------------------------------------- 1 | DEFAULT_COMMAND_TIMEOUT = 35 # In seconds 2 | DEFAULT_PER_DEVICE_TIMEOUT = 8 # In seconds 3 | DEFAULT_COMMAND_RETRIES = 0 4 | DEFAULT_UPDATE_RETRIES = 0 5 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | bt-mqtt-gateway: 5 | image: zewelor/bt-mqtt-gateway 6 | container_name: bt-mqtt-gateway 7 | restart: always 8 | # Uncomment to enable debug 9 | #environment: 10 | # - DEBUG=true 11 | volumes: 12 | - ./config.yaml:/application/config.yaml 13 | cap_add: 14 | - NET_ADMIN 15 | - NET_RAW 16 | network_mode: host 17 | -------------------------------------------------------------------------------- /exceptions.py: -------------------------------------------------------------------------------- 1 | class WorkerTimeoutError(Exception): 2 | pass 3 | 4 | 5 | class DeviceTimeoutError(Exception): 6 | pass 7 | -------------------------------------------------------------------------------- /gateway.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | 5 | from exceptions import WorkerTimeoutError, DeviceTimeoutError 6 | 7 | if sys.version_info < (3, 5): 8 | print("To use this script you need python 3.5 or newer! got %s" % sys.version_info) 9 | sys.exit(1) 10 | 11 | import logger 12 | 13 | logger.setup() 14 | 15 | import logging 16 | import argparse 17 | import queue 18 | 19 | import workers_requirements 20 | from workers_queue import _WORKERS_QUEUE 21 | from mqtt import MqttClient 22 | from workers_manager import WorkersManager 23 | 24 | 25 | parser = argparse.ArgumentParser() 26 | group = parser.add_mutually_exclusive_group() 27 | group.add_argument( 28 | "-d", 29 | "--debug", 30 | action="store_true", 31 | default=False, 32 | help="Set logging to output debug information", 33 | ) 34 | group.add_argument( 35 | "-q", 36 | "--quiet", 37 | action="store_true", 38 | default=False, 39 | help="Set logging to just output warnings and errors", 40 | ) 41 | parser.add_argument( 42 | "-s", 43 | "--suppress-update-failures", 44 | dest="suppress", 45 | action="store_true", 46 | default=False, 47 | help="Suppress any errors regarding failed device updates", 48 | ) 49 | parser.add_argument("-r", "--requirements", type=str, choices=['all', 'configured'], 50 | help="Print all or configured only required python libs") 51 | parsed = parser.parse_args() 52 | 53 | if parsed.requirements: 54 | requirements = [] 55 | if parsed.requirements == 'configured': 56 | requirements = workers_requirements.configured_workers() 57 | else: 58 | requirements = workers_requirements.all_workers() 59 | 60 | print(' '.join(requirements)) 61 | exit(0) 62 | 63 | from config import settings 64 | 65 | _LOGGER = logger.get() 66 | if parsed.quiet: 67 | _LOGGER.setLevel(logging.WARNING) 68 | elif parsed.debug: 69 | _LOGGER.setLevel(logging.DEBUG) 70 | logger.enable_debug_formatter() 71 | else: 72 | _LOGGER.setLevel(logging.INFO) 73 | logger.suppress_update_failures(parsed.suppress) 74 | 75 | _LOGGER.info("Starting") 76 | 77 | workers_requirements.verify() 78 | 79 | global_topic_prefix = settings["mqtt"].get("topic_prefix") 80 | 81 | mqtt = MqttClient(settings["mqtt"]) 82 | manager = WorkersManager(settings["manager"], mqtt) 83 | manager.register_workers(global_topic_prefix) 84 | manager.start() 85 | 86 | running = True 87 | 88 | while running: 89 | try: 90 | mqtt.publish(_WORKERS_QUEUE.get(timeout=10).execute()) 91 | except queue.Empty: # Allow for SIGINT processing 92 | pass 93 | except (WorkerTimeoutError, DeviceTimeoutError) as e: 94 | logger.log_exception( 95 | _LOGGER, 96 | str(e) if str(e) else "Timeout while executing worker command", 97 | suppress=True, 98 | ) 99 | except (KeyboardInterrupt, SystemExit): 100 | running = False 101 | _LOGGER.info( 102 | "Finish current jobs and shut down. If you need force exit use kill" 103 | ) 104 | except Exception as e: 105 | logger.log_exception( 106 | _LOGGER, "Fatal error while executing worker command: %s", type(e).__name__ 107 | ) 108 | raise e 109 | -------------------------------------------------------------------------------- /hooks/post_push: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | curl -Lo manifest-tool https://github.com/estesp/manifest-tool/releases/download/v0.9.0/manifest-tool-linux-amd64 4 | chmod +x manifest-tool 5 | 6 | ./manifest-tool push from-spec multi-arch-manifest.yaml 7 | -------------------------------------------------------------------------------- /hooks/pre_build: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker run --rm --privileged multiarch/qemu-user-static:register --reset 4 | -------------------------------------------------------------------------------- /logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.config 3 | import yaml 4 | 5 | APP_ROOT = "bt-mqtt-gw" 6 | SUPPRESSION_ENABLED = False 7 | 8 | 9 | def setup(): 10 | with open("logger.yaml", "rt") as f: 11 | config = yaml.safe_load(f.read()) 12 | logging.config.dictConfig(config) 13 | 14 | 15 | def get(name=None): 16 | if name: 17 | logger_name = "{}.{}".format(APP_ROOT, name) 18 | else: 19 | logger_name = APP_ROOT 20 | return logging.getLogger(logger_name) 21 | 22 | 23 | def enable_debug_formatter(): 24 | logging.getLogger().handlers[0].setFormatter( 25 | logging.getLogger("dummy_debug").handlers[0].formatter 26 | ) 27 | 28 | 29 | def reset(): 30 | app_level = get().getEffectiveLevel() 31 | 32 | root = logging.getLogger() 33 | map(root.removeHandler, root.handlers[:]) 34 | map(root.removeFilter, root.filters[:]) 35 | 36 | setup() 37 | get().setLevel(app_level) 38 | if app_level <= logging.DEBUG: 39 | enable_debug_formatter() 40 | 41 | 42 | def suppress_update_failures(suppress): 43 | global SUPPRESSION_ENABLED 44 | SUPPRESSION_ENABLED = suppress 45 | 46 | 47 | def log_exception(logger, message, *args, **kwargs): 48 | if not (kwargs.pop('suppress', False) and SUPPRESSION_ENABLED): 49 | if logger.isEnabledFor(logging.DEBUG): 50 | logger.exception(message, *args, **kwargs) 51 | elif logger.isEnabledFor(logging.WARNING): 52 | logger.warning(message, *args, **kwargs) 53 | -------------------------------------------------------------------------------- /logger.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | disable_existing_loggers: True 3 | 4 | formatters: 5 | default: 6 | format: '%(asctime)s %(message)s' 7 | datefmt: '%X' 8 | minimal: 9 | format: '%(message)s' 10 | debug: 11 | format: '%(asctime)s %(levelname)s %(name)s %(filename)s:%(lineno)d:%(funcName)s - %(message)s' 12 | 13 | handlers: 14 | console: 15 | class: logging.StreamHandler 16 | formatter: default 17 | stream: ext://sys.stdout 18 | dummy_debug: 19 | class: logging.NullHandler 20 | formatter: debug 21 | 22 | loggers: 23 | bt-mqtt-gw: 24 | level: INFO 25 | dummy_debug: 26 | handlers: [dummy_debug] 27 | 28 | root: 29 | handlers: [console] 30 | -------------------------------------------------------------------------------- /mqtt.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import paho.mqtt.client as mqtt 4 | import logger 5 | 6 | LWT_ONLINE = "online" 7 | LWT_OFFLINE = "offline" 8 | _LOGGER = logger.get(__name__) 9 | 10 | 11 | class MqttClient: 12 | def __init__(self, config): 13 | self._config = config 14 | self._mqttc = mqtt.Client( 15 | client_id=self.client_id, 16 | clean_session=False, 17 | userdata={"global_topic_prefix": self.topic_prefix}, 18 | ) 19 | 20 | if self.username and self.password and not self.client_key: 21 | self.mqttc.username_pw_set(self.username, self.password) 22 | 23 | if self.ca_cert and not self.client_key: 24 | cert_reqs = mqtt.ssl.CERT_REQUIRED if self.ca_verify else mqtt.ssl.CERT_NONE 25 | self.mqttc.tls_set(self.ca_cert, cert_reqs=cert_reqs) 26 | self.mqttc.tls_insecure_set(not self.ca_verify) 27 | 28 | if self.ca_cert and self.client_key: 29 | cert_reqs = mqtt.ssl.CERT_REQUIRED if self.ca_verify else mqtt.ssl.CERT_NONE 30 | self.mqttc.tls_set(self.ca_cert, self.client_cert, self.client_key, cert_reqs=cert_reqs) 31 | self.mqttc.tls_insecure_set(not self.ca_verify) 32 | 33 | if self.availability_topic: 34 | topic = self._format_topic(self.availability_topic) 35 | _LOGGER.debug("Setting LWT to: %s" % topic) 36 | self.mqttc.will_set(topic, payload=LWT_OFFLINE, retain=True) 37 | 38 | def publish(self, messages): 39 | if not messages: 40 | return 41 | 42 | for m in messages: 43 | if m.use_global_prefix: 44 | topic = self._format_topic(m.topic) 45 | else: 46 | topic = m.topic 47 | self.mqttc.publish(topic, m.payload, retain=m.retain) 48 | 49 | @property 50 | def client_id(self): 51 | return ( 52 | self._config["client_id"] 53 | if "client_id" in self._config 54 | else "bt-mqtt-gateway" 55 | ) 56 | 57 | @property 58 | def hostname(self): 59 | return self._config["host"] 60 | 61 | @property 62 | def port(self): 63 | return self._config["port"] if "port" in self._config else 1883 64 | 65 | @property 66 | def username(self): 67 | return str(self._config["username"]) if "username" in self._config else None 68 | 69 | @property 70 | def password(self): 71 | return str(self._config["password"]) if "password" in self._config else None 72 | 73 | @property 74 | def ca_cert(self): 75 | return self._config["ca_cert"] if "ca_cert" in self._config else None 76 | 77 | @property 78 | def client_cert(self): 79 | return self._config["client_cert"] if "client_cert" in self._config else None 80 | 81 | @property 82 | def client_key(self): 83 | return self._config["client_key"] if "client_key" in self._config else None 84 | 85 | @property 86 | def ca_verify(self): 87 | if "ca_verify" in self._config: 88 | # Constrain config input to boolean value 89 | if self._config["ca_verify"]: 90 | return True 91 | else: 92 | return False 93 | else: 94 | return True 95 | 96 | @property 97 | def topic_prefix(self): 98 | return self._config["topic_prefix"] if "topic_prefix" in self._config else None 99 | 100 | @property 101 | def availability_topic(self): 102 | return ( 103 | self._config["availability_topic"] 104 | if "availability_topic" in self._config 105 | else None 106 | ) 107 | 108 | @property 109 | def mqttc(self): 110 | return self._mqttc 111 | 112 | # noinspection PyUnusedLocal 113 | def on_connect(self, client, userdata, flags, rc): 114 | if self.availability_topic: 115 | self.publish( 116 | [ 117 | MqttMessage( 118 | topic=self.availability_topic, payload=LWT_ONLINE, retain=True 119 | ) 120 | ] 121 | ) 122 | 123 | def callbacks_subscription(self, callbacks): 124 | self.mqttc.on_connect = self.on_connect 125 | 126 | self.mqttc.connect(self.hostname, port=self.port) 127 | 128 | for topic, callback in callbacks: 129 | topic = self._format_topic(topic) 130 | _LOGGER.debug("Subscribing to: %s" % topic) 131 | self.mqttc.message_callback_add(topic, callback) 132 | self.mqttc.subscribe(topic) 133 | 134 | self.mqttc.loop_start() 135 | 136 | def __del__(self): 137 | if self.availability_topic: 138 | self.publish( 139 | [ 140 | MqttMessage( 141 | topic=self.availability_topic, payload=LWT_OFFLINE, retain=True 142 | ) 143 | ] 144 | ) 145 | 146 | def _format_topic(self, topic): 147 | return "{}/{}".format(self.topic_prefix, topic) if self.topic_prefix else topic 148 | 149 | 150 | class MqttMessage: 151 | use_global_prefix = True 152 | 153 | def __init__(self, topic=None, payload=None, retain=False): 154 | self._topic = topic 155 | self._payload = payload 156 | self._retain = retain 157 | 158 | @property 159 | def topic(self): 160 | return self._topic 161 | 162 | @topic.setter 163 | def topic(self, new_topic): 164 | self._topic = new_topic 165 | 166 | @property 167 | def payload(self): 168 | if isinstance(self.raw_payload, str): 169 | return self.raw_payload 170 | else: 171 | return json.dumps(self.raw_payload) 172 | 173 | @property 174 | def raw_payload(self): 175 | return self._payload 176 | 177 | @property 178 | def retain(self): 179 | return self._retain 180 | 181 | @retain.setter 182 | def retain(self, new_retain): 183 | self._retain = new_retain 184 | 185 | @property 186 | def as_dict(self): 187 | return {"topic": self.topic, "payload": self.payload} 188 | 189 | def __repr__(self): 190 | return self.as_dict.__str__() 191 | 192 | def __str__(self): 193 | return self.__repr__() 194 | 195 | 196 | class MqttConfigMessage(MqttMessage): 197 | SENSOR = "sensor" 198 | CLIMATE = "climate" 199 | BINARY_SENSOR = "binary_sensor" 200 | COVER = "cover" 201 | SWITCH = "switch" 202 | 203 | use_global_prefix = False 204 | 205 | def __init__(self, component, name, payload=None, retain=False): 206 | super().__init__("{}/{}/config".format(component, name), payload, retain) 207 | -------------------------------------------------------------------------------- /multi-arch-manifest.yaml: -------------------------------------------------------------------------------- 1 | image: zewelor/bt-mqtt-gateway:latest 2 | manifests: 3 | - image: zewelor/bt-mqtt-gateway:amd64 4 | platform: 5 | architecture: amd64 6 | os: linux 7 | - image: zewelor/bt-mqtt-gateway:arm32v7 8 | platform: 9 | architecture: arm 10 | os: linux 11 | variant: v7 12 | - image: zewelor/bt-mqtt-gateway:arm64v8 13 | platform: 14 | architecture: arm64 15 | os: linux 16 | variant: v8 17 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | paho-mqtt 2 | pyyaml 3 | interruptingcow 4 | apscheduler 5 | tenacity 6 | -------------------------------------------------------------------------------- /service.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | SCRIPT_DIR=$( cd "$( dirname "$0" )" >/dev/null 2>&1 && pwd ) 4 | VIRTUAL_ENV=$SCRIPT_DIR/.venv 5 | if [ -d "$VIRTUAL_ENV" ]; then 6 | export VIRTUAL_ENV 7 | PATH="$VIRTUAL_ENV/bin:$PATH" 8 | export PATH 9 | fi 10 | cd "$SCRIPT_DIR" 11 | sudo python3 ./gateway.py "$@" 12 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if ! [ -f '/application/config.yaml' ]; then 4 | echo "There is no config.yaml! Check example config: https://github.com/zewelor/bt-mqtt-gateway/blob/master/config.yaml.example" 5 | exit 1 6 | fi 7 | 8 | if [ "$DEBUG" = 'true' ]; then 9 | echo "Start in debug mode" 10 | python3 ./gateway.py -d 11 | status=$? 12 | echo "Gateway died..." 13 | exit $status 14 | else 15 | echo "Start in normal mode" 16 | python3 ./gateway.py 17 | fi 18 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | true_statement = ("y", "yes", "on", "1", "true", "t") 2 | 3 | 4 | def booleanize(value) -> bool: 5 | """ 6 | This function will try to assume that provided string value is boolean in some way. It will accept a wide range 7 | of values for strings like ('y', 'yes', 'on', '1', 'true' and 't'. Any other value will be treated as false 8 | :param value: any value 9 | :return: boolean statement 10 | """ 11 | if isinstance(value, str): 12 | return value.lower() in true_statement 13 | return bool(value) 14 | -------------------------------------------------------------------------------- /workers/am43.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | 4 | import logger 5 | from const import DEFAULT_PER_DEVICE_TIMEOUT 6 | from mqtt import MqttMessage, MqttConfigMessage 7 | from workers.base import BaseWorker, retry 8 | 9 | _LOGGER = logger.get(__name__) 10 | 11 | REQUIREMENTS = [ 12 | "git+https://github.com/andrey-yantsen/python-zemismart-roller-shade.git" 13 | "@61a9a38656910e5ff74c45d7e3309eada5edcd01#egg=Zemismart" 14 | ] 15 | 16 | 17 | class Am43Worker(BaseWorker): 18 | per_device_timeout = DEFAULT_PER_DEVICE_TIMEOUT # type: int 19 | target_range_scale = 3 # type: int 20 | last_target_position = 255 21 | 22 | def _setup(self): 23 | self._last_position_by_device = {device['mac']: 255 for device in self.devices.values()} 24 | self._last_device_update = {device['mac']: 0 for device in self.devices.values()} 25 | 26 | if not hasattr(self, 'default_update_interval'): 27 | self.default_update_interval = None 28 | 29 | if not hasattr(self, 'rapid_update_interval'): 30 | self.rapid_update_interval = None 31 | 32 | self.update_interval = self.default_update_interval 33 | self.availability_topic = None 34 | 35 | _LOGGER.info("Adding %d %s devices", len(self.devices), repr(self)) 36 | 37 | def config(self, availability_topic): 38 | ret = [] 39 | for name, data in self.devices.items(): 40 | ret += self.config_device(name, data, availability_topic) 41 | return ret 42 | 43 | def _get_hass_device_description(self, name): 44 | device_class = self.devices[name].get('hass_device_class', 'shade') 45 | return { 46 | 'identifiers': [self.devices[name]['mac'], self.format_discovery_id(self.devices[name]['mac'], name)], 47 | 'manufacturer': 'A-OK', 48 | 'model': 'AM43', 49 | 'name': '{} ({})'.format(device_class.title(), name), 50 | } 51 | 52 | def config_device(self, name, data, availability_topic): 53 | ret = [] 54 | device = self._get_hass_device_description(name) 55 | ret.append( 56 | MqttConfigMessage( 57 | MqttConfigMessage.COVER, 58 | self.format_discovery_topic(data['mac'], name, 'shade'), 59 | payload={ 60 | 'device_class': data.get('hass_device_class', 'shade'), 61 | 'unique_id': self.format_discovery_id('am43', name, data['mac']), 62 | 'name': 'AM43 Blinds ({})'.format(name), 63 | 'availability_topic': "{}/{}".format(self.global_topic_prefix, availability_topic), 64 | 'device': device, 65 | 'position_open': 0 + self.target_range_scale, 66 | 'position_closed': 100 - self.target_range_scale, 67 | 'set_position_topic': '~/targetPosition/set', 68 | 'position_topic': '~/currentPosition', 69 | 'state_topic': '~/positionState', 70 | 'command_topic': '~/positionState/set', 71 | '~': self.format_prefixed_topic(name), 72 | } 73 | ) 74 | ) 75 | ret.append( 76 | MqttConfigMessage( 77 | MqttConfigMessage.SENSOR, 78 | self.format_discovery_topic(data['mac'], name, 'shade', 'battery'), 79 | payload={ 80 | 'device_class': 'battery', 81 | 'unique_id': self.format_discovery_id('am43', name, data['mac'], 'battery'), 82 | 'name': 'AM43 Blinds ({}) battery'.format(name), 83 | 'availability_topic': "{}/{}".format(self.global_topic_prefix, availability_topic), 84 | '~': self.format_prefixed_topic(name), 85 | 'unit_of_measurement': '%', 86 | 'state_topic': '~/battery', 87 | 'device': device, 88 | } 89 | ) 90 | ) 91 | self.availability_topic = "{}/{}".format(self.global_topic_prefix, availability_topic) 92 | return ret 93 | 94 | def configure_device_timer(self, device_name, timer_id, timer): 95 | from config import settings 96 | 97 | if not settings.get('manager', {}).get('sensor_config'): 98 | return 99 | 100 | data = self.devices[device_name] 101 | device = self._get_hass_device_description(device_name) 102 | 103 | timer_alias = 'timer{}'.format(timer_id) 104 | 105 | if timer is None: 106 | payload = '' 107 | else: 108 | payload = { 109 | 'unique_id': self.format_discovery_id('am43', device_name, data['mac'], timer_alias), 110 | 'name': 'AM43 Blinds ({}) Timer {}: Set to {}% at {}'.format(device_name, timer_id + 1, 111 | timer['position'], timer['time']), 112 | 'availability_topic': self.availability_topic, 113 | 'device': device, 114 | 'state_topic': '~/{}'.format(timer_alias), 115 | 'command_topic': '~/{}/set'.format(timer_alias), 116 | '~': self.format_prefixed_topic(device_name), 117 | } 118 | 119 | # Creepy way to do HASS sensors not only during the configuration time 120 | return MqttConfigMessage( 121 | component=settings['manager']["sensor_config"].get("topic", "homeassistant"), 122 | name='{}/{}'.format( 123 | MqttConfigMessage.SWITCH, 124 | self.format_discovery_topic(data['mac'], device_name, 'shade', timer_alias) 125 | ), 126 | payload=payload, 127 | retain=settings['manager']["sensor_config"].get("retain", True) 128 | ) 129 | 130 | # Based on the accessory configuration, this will either 131 | # return the supplied value right back, or will invert 132 | # it so 100 is considered open instead of closed 133 | def correct_value(self, data, value): 134 | if "invert" in data.keys() and data["invert"]: 135 | return abs(value - 100) 136 | else: 137 | return value 138 | 139 | def get_device_state(self, device_name, data, shade): 140 | from Zemismart import Zemismart 141 | 142 | battery = 0 143 | retry_attempts = 0 144 | while battery == 0 and retry_attempts < self.update_retries: 145 | retry_attempts += 1 146 | 147 | # The docs for this library say that sometimes this needs called 148 | # multiple times, try up to 5 until we get a battery number 149 | if not shade.update(): 150 | continue 151 | 152 | battery = shade.battery 153 | 154 | if battery > 0: 155 | if self.last_target_position == 255: 156 | # initial unknown value, set to current position 157 | # 158 | # We don't pass this to correct_value as we want internal state 159 | # to agree with the device 160 | self.last_target_position = shade.position 161 | 162 | time_from_last_update = time.time() - self._last_device_update[data['mac']] 163 | 164 | shade_position = self.correct_value(data, shade.position) 165 | target_position = self.correct_value(data, self.last_target_position) 166 | 167 | previous_position = self._last_position_by_device[data['mac']] 168 | state = 'stopped' 169 | if shade_position <= self.target_range_scale: 170 | state = 'open' 171 | elif shade_position >= 100 - self.target_range_scale: 172 | state = 'closed' 173 | 174 | if self.rapid_update_interval \ 175 | and time_from_last_update <= self.rapid_update_interval * self.update_retries \ 176 | and previous_position != 255: 177 | if previous_position < shade_position: 178 | state = 'closing' 179 | elif previous_position > shade_position: 180 | state = 'opening' 181 | 182 | self._last_position_by_device[data['mac']] = shade_position 183 | self._last_device_update[data['mac']] = time.time() 184 | 185 | return { 186 | "currentPosition": shade_position, 187 | "targetPosition": target_position, 188 | "battery": shade.battery, 189 | "positionState": state, 190 | "time_from_last_update": time_from_last_update, 191 | "timers": [ 192 | { 193 | 'enabled': timer.enabled, 194 | 'position': timer.position, 195 | 'time': '{:02d}:{:02d}'.format(timer.hours, timer.minutes), 196 | 'repeat': { 197 | 'Monday': timer.repeats & Zemismart.Timer.REPEAT_MONDAY != 0, 198 | 'Tuesday': timer.repeats & Zemismart.Timer.REPEAT_TUESDAY != 0, 199 | 'Wednesday': timer.repeats & Zemismart.Timer.REPEAT_WEDNESDAY != 0, 200 | 'Thursday': timer.repeats & Zemismart.Timer.REPEAT_THURSDAY != 0, 201 | 'Friday': timer.repeats & Zemismart.Timer.REPEAT_FRIDAY != 0, 202 | 'Saturday': timer.repeats & Zemismart.Timer.REPEAT_SATURDAY != 0, 203 | 'Sunday': timer.repeats & Zemismart.Timer.REPEAT_SUNDAY != 0, 204 | } 205 | } 206 | for timer in shade.timers 207 | ], 208 | } 209 | else: 210 | _LOGGER.debug("Got battery state 0 for '%s' (%s)", device_name, data["mac"]) 211 | 212 | def create_mqtt_messages(self, device_name, device_state): 213 | ret = [ 214 | MqttMessage( 215 | topic=self.format_topic(device_name), 216 | payload=json.dumps(device_state) 217 | ), 218 | MqttMessage( 219 | topic=self.format_topic(device_name, "currentPosition"), 220 | payload=device_state["currentPosition"], 221 | retain=True 222 | ), 223 | MqttMessage( 224 | topic=self.format_topic(device_name, "targetPosition"), 225 | payload=device_state["targetPosition"] 226 | ), 227 | MqttMessage( 228 | topic=self.format_topic(device_name, "battery"), 229 | payload=device_state["battery"], 230 | retain=True 231 | ), 232 | MqttMessage( 233 | topic=self.format_topic(device_name, "positionState"), 234 | payload=device_state["positionState"] 235 | ) 236 | ] 237 | 238 | for timer_id, timer in enumerate(device_state['timers']): 239 | hass = self.configure_device_timer(device_name, timer_id, timer) 240 | if hass: 241 | ret.append(hass) 242 | ret.append( 243 | MqttMessage( 244 | topic=self.format_topic(device_name, "timer{}".format(timer_id)), 245 | payload='ON' if timer['enabled'] else 'OFF' 246 | ) 247 | ) 248 | 249 | for timer_id in range(len(device_state['timers']), 4): 250 | hass = self.configure_device_timer(device_name, timer_id, None) 251 | if hass: 252 | ret.append(hass) 253 | 254 | return ret 255 | 256 | def single_device_status_update(self, device_name, data): 257 | _LOGGER.debug("Updating %s device '%s' (%s)", repr(self), device_name, data["mac"]) 258 | 259 | from Zemismart import Zemismart 260 | shade = Zemismart(data["mac"], data["pin"], max_connect_time=self.per_device_timeout, 261 | withMutex=True, iface=data.get('iface')) 262 | with shade: 263 | ret = [] 264 | device_state = self.get_device_state(device_name, data, shade) 265 | ret += self.create_mqtt_messages(device_name, device_state) 266 | 267 | if self.rapid_update_interval and self.default_update_interval: 268 | if device_state['positionState'].endswith('ing') and self.default_update_interval == self.update_interval: 269 | ret.append( 270 | MqttMessage( 271 | topic=self.format_topic('update_interval'), 272 | payload=self.rapid_update_interval 273 | ) 274 | ) 275 | self.update_interval = self.rapid_update_interval 276 | elif not device_state['positionState'].endswith('ing') and self.default_update_interval != self.update_interval: 277 | ret.append( 278 | MqttMessage( 279 | topic=self.format_topic('update_interval'), 280 | payload=self.default_update_interval 281 | ) 282 | ) 283 | self.update_interval = self.default_update_interval 284 | 285 | return ret 286 | 287 | def status_update(self): 288 | _LOGGER.info("Updating %d %s devices", len(self.devices), repr(self)) 289 | 290 | for device_name, data in self.devices.items(): 291 | yield retry(self.single_device_status_update, retries=self.update_retries)(device_name, data) 292 | 293 | def set_state(self, state, device_name): 294 | from Zemismart import Zemismart 295 | 296 | ret = [] 297 | data = self.devices[device_name] 298 | shade = Zemismart(data["mac"], data["pin"], max_connect_time=self.per_device_timeout, 299 | withMutex=True, iface=data.get('iface')) 300 | with shade: 301 | device_state = self.get_device_state(device_name, data, shade) 302 | device_position = self.correct_value(data, device_state["currentPosition"]) 303 | 304 | if state == 'STOP': 305 | if not shade.stop(): 306 | raise AttributeError('shade.stop() failed') 307 | 308 | device_state.update({ 309 | "currentPosition": device_position, 310 | "targetPosition": device_position, 311 | "battery": shade.battery, 312 | "positionState": 'stopped' 313 | }) 314 | self.last_target_position = device_position 315 | 316 | if self.default_update_interval and self.rapid_update_interval: 317 | self.update_interval = self.default_update_interval 318 | ret.append( 319 | MqttMessage( 320 | topic=self.format_topic('update_interval'), 321 | payload=self.default_update_interval 322 | ) 323 | ) 324 | 325 | ret += self.create_mqtt_messages(device_name, device_state) 326 | elif state == 'OPEN' and device_position > self.target_range_scale: 327 | shade.stop() 328 | 329 | # Yes, for open command we need to call close(), because "closed blinds" in AM43 330 | # means that they're hidden, and the window is full open 331 | if not shade.close(): 332 | raise AttributeError('shade.close() failed') 333 | device_state.update({ 334 | "currentPosition": device_position, 335 | "targetPosition": 0, 336 | "battery": shade.battery, 337 | "positionState": 'opening' 338 | }) 339 | self.last_target_position = 0 340 | 341 | if self.default_update_interval and self.rapid_update_interval: 342 | self.update_interval = self.rapid_update_interval 343 | ret.append( 344 | MqttMessage( 345 | topic=self.format_topic('update_interval'), 346 | payload=self.rapid_update_interval 347 | ) 348 | ) 349 | 350 | ret += self.create_mqtt_messages(device_name, device_state) 351 | elif state == 'CLOSE' and device_position < 100 - self.target_range_scale: 352 | shade.stop() 353 | 354 | # Same as above for 'OPEN': we need to call open() when want to close() the window 355 | if not shade.open(): 356 | raise AttributeError('shade.open() failed') 357 | device_state.update({ 358 | "currentPosition": device_position, 359 | "targetPosition": 100, 360 | "battery": shade.battery, 361 | "positionState": 'closing' 362 | }) 363 | self.last_target_position = 100 364 | 365 | ret += self.create_mqtt_messages(device_name, device_state) 366 | 367 | if self.default_update_interval and self.rapid_update_interval: 368 | self.update_interval = self.rapid_update_interval 369 | ret.append( 370 | MqttMessage( 371 | topic=self.format_topic('update_interval'), 372 | payload=self.rapid_update_interval 373 | ) 374 | ) 375 | return ret 376 | 377 | def set_position(self, position, device_name): 378 | from Zemismart import Zemismart 379 | 380 | ret = [] 381 | 382 | # internal state of the target position should align with the scale used by the 383 | # device 384 | if self.target_range_scale <= int(position) <= 100 - self.target_range_scale: 385 | position = int((int(position) - self.target_range_scale) * (100 / (100 - self.target_range_scale * 2))) 386 | 387 | data = self.devices[device_name] 388 | target_position = self.correct_value(data, int(position)) 389 | self.last_target_position = target_position 390 | 391 | shade = Zemismart(data["mac"], data["pin"], max_connect_time=self.per_device_timeout, 392 | withMutex=True, iface=data.get('iface')) 393 | with shade: 394 | # get the current state so we can work out direction for update messages 395 | # after getting this, convert so we are using the device scale for 396 | # values 397 | device_state = self.get_device_state(device_name, data, shade) 398 | device_position = self.correct_value(data, device_state["currentPosition"]) 399 | 400 | if device_position == target_position: 401 | # no update required, not moved 402 | _LOGGER.debug("Position for device '%s' (%s) matches, %s %s", 403 | device_name, data["mac"], device_position, position) 404 | return [] 405 | else: 406 | # work out the direction 407 | # this compares the values using the caller scale instead 408 | # of the internal scale. 409 | if device_state["currentPosition"] < int(position): 410 | state = "closing" 411 | else: 412 | state = "opening" 413 | 414 | # send the new position 415 | if not shade.set_position(target_position): 416 | raise AttributeError('shade.set_position() failed') 417 | 418 | device_state.update({ 419 | "currentPosition": device_position, 420 | "targetPosition": target_position, 421 | "battery": shade.battery, 422 | "positionState": state 423 | }) 424 | 425 | ret += self.create_mqtt_messages(device_name, device_state) 426 | 427 | if self.default_update_interval and self.rapid_update_interval: 428 | self.update_interval = self.rapid_update_interval 429 | ret.append( 430 | MqttMessage( 431 | topic=self.format_topic('update_interval'), 432 | payload=self.rapid_update_interval 433 | ) 434 | ) 435 | return ret 436 | 437 | def set_timer_state(self, timer_id, state, device_name): 438 | from Zemismart import Zemismart 439 | 440 | ret = [] 441 | 442 | data = self.devices[device_name] 443 | target_state = True if state == 'ON' else False 444 | 445 | shade = Zemismart(data["mac"], data["pin"], max_connect_time=self.per_device_timeout, 446 | withMutex=True, iface=data.get('iface')) 447 | with shade: 448 | shade.update() 449 | shade.timer_toggle(timer_id, target_state) 450 | device_state = self.get_device_state(device_name, data, shade) 451 | ret += self.create_mqtt_messages(device_name, device_state) 452 | 453 | return ret 454 | 455 | def handle_mqtt_command(self, topic, value): 456 | topic_without_prefix = topic.replace("{}/".format(self.topic_prefix), "") 457 | device_name, field, action = topic_without_prefix.split("/") 458 | ret = [] 459 | 460 | if device_name in self.devices: 461 | data = self.devices[device_name] 462 | _LOGGER.debug("On command got device %s %s", device_name, data) 463 | else: 464 | _LOGGER.error("Ignore command because device %s is unknown", device_name) 465 | return ret 466 | 467 | value = value.decode("utf-8") 468 | if field == "positionState" and action == "set": 469 | ret += self.set_state(value, device_name) 470 | elif field == "targetPosition" and action == "set": 471 | ret += self.set_position(value, device_name) 472 | elif field.startswith('timer') and action == "set": 473 | ret += self.set_timer_state(int(field[-1]), value, device_name) 474 | elif field == "get" or action == "get": 475 | ret += retry(self.single_device_status_update, retries=self.update_retries)(device_name, data) 476 | 477 | return ret 478 | 479 | def on_command(self, topic, value): 480 | _LOGGER.info("On command called with %s %s", topic, value) 481 | return retry(self.handle_mqtt_command, retries=self.command_retries)(topic, value) 482 | -------------------------------------------------------------------------------- /workers/base.py: -------------------------------------------------------------------------------- 1 | import logger 2 | 3 | import functools 4 | import logging 5 | 6 | import tenacity 7 | 8 | _LOGGER = logger.get(__name__) 9 | 10 | 11 | class BaseWorker: 12 | def __init__(self, command_timeout, command_retries, update_retries, global_topic_prefix, **kwargs): 13 | self.command_timeout = command_timeout 14 | self.command_retries = command_retries 15 | self.update_retries = update_retries 16 | self.global_topic_prefix = global_topic_prefix 17 | for arg, value in kwargs.items(): 18 | setattr(self, arg, value) 19 | self._setup() 20 | 21 | def _setup(self): 22 | return 23 | 24 | def format_discovery_topic(self, mac, *sensor_args): 25 | node_id = mac.replace(":", "-") 26 | object_id = "_".join([repr(self), *sensor_args]) 27 | return "{}/{}".format(node_id, object_id) 28 | 29 | def format_discovery_id(self, mac, *sensor_args): 30 | return "bt-mqtt-gateway/{}".format( 31 | self.format_discovery_topic(mac, *sensor_args) 32 | ) 33 | 34 | def format_discovery_name(self, *sensor_args): 35 | return "_".join([repr(self), *sensor_args]) 36 | 37 | def format_topic(self, *topic_args): 38 | return "/".join([self.topic_prefix, *topic_args]) 39 | 40 | def format_prefixed_topic(self, *topic_args): 41 | topic = self.format_topic(*topic_args) 42 | if self.global_topic_prefix: 43 | return "{}/{}".format(self.global_topic_prefix, topic) 44 | return topic 45 | 46 | def __repr__(self): 47 | return self.__module__.split(".")[-1] 48 | 49 | @staticmethod 50 | def true_false_to_ha_on_off(true_false): 51 | if true_false: 52 | return 'ON' 53 | 54 | return 'OFF' 55 | 56 | def log_update_exception(self, named_logger, dev_name, exception): 57 | logger.log_exception( 58 | named_logger, 59 | "Error during update of %s device '%s': %s", 60 | repr(self), 61 | dev_name, 62 | type(exception).__name__, 63 | suppress=True, 64 | ) 65 | 66 | def log_timeout_exception(self, named_logger, dev_name): 67 | logger.log_exception( 68 | named_logger, 69 | "Time out during update of %s device '%s'", 70 | repr(self), 71 | dev_name, 72 | suppress=True, 73 | ) 74 | 75 | def log_connect_exception(self, named_logger, dev_name, exception): 76 | logger.log_exception( 77 | named_logger, 78 | "Failed connect from %s to device '%s': %s", 79 | repr(self), 80 | dev_name, 81 | type(exception).__name__, 82 | suppress=True, 83 | ) 84 | 85 | def log_unspecified_exception(self, named_logger, dev_name, exception): 86 | logger.log_exception( 87 | named_logger, 88 | "Failed btle from %s to device '%s': %s", 89 | repr(self), 90 | dev_name, 91 | type(exception).__name__, 92 | suppress=True, 93 | ) 94 | 95 | def retry(_func=None, *, retries=0, exception_type=Exception): 96 | def log_retry(retry_state): 97 | _LOGGER.info( 98 | 'Call to %s failed the %s time (%s). Retrying in %s seconds', 99 | '.'.join((retry_state.fn.__module__, retry_state.fn.__name__)), 100 | retry_state.attempt_number, 101 | type(retry_state.outcome.exception()).__name__, 102 | '{:.2f}'.format(getattr(retry_state.next_action, 'sleep'))) 103 | 104 | def decorator_retry(func): 105 | @functools.wraps(func) 106 | def wrapped_retry(*args, **kwargs): 107 | retryer = tenacity.Retrying( 108 | wait=tenacity.wait_random(1, 3), 109 | retry=tenacity.retry_if_exception_type(exception_type), 110 | stop=tenacity.stop_after_attempt(retries+1), 111 | reraise=True, 112 | before_sleep=log_retry) 113 | return retryer(func, *args, **kwargs) 114 | return wrapped_retry 115 | 116 | if _func: 117 | return decorator_retry(_func) 118 | else: 119 | return decorator_retry 120 | -------------------------------------------------------------------------------- /workers/blescanmulti.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from mqtt import MqttMessage 4 | 5 | from workers.base import BaseWorker 6 | from utils import booleanize 7 | import logger 8 | 9 | REQUIREMENTS = ["bluepy"] 10 | _LOGGER = logger.get(__name__) 11 | 12 | 13 | class BleDeviceStatus: 14 | def __init__( 15 | self, 16 | worker, 17 | mac: str, 18 | name: str, 19 | available: bool = False, 20 | last_status_time: float = None, 21 | message_sent: bool = True, 22 | ): 23 | if last_status_time is None: 24 | last_status_time = time.time() 25 | 26 | self.worker = worker # type: BlescanmultiWorker 27 | self.mac = mac.lower() 28 | self.name = name 29 | self.available = available 30 | self.last_status_time = last_status_time 31 | self.message_sent = message_sent 32 | 33 | def set_status(self, available): 34 | if available != self.available: 35 | self.available = available 36 | self.last_status_time = time.time() 37 | self.message_sent = False 38 | 39 | def _timeout(self): 40 | if self.available: 41 | return self.worker.available_timeout 42 | else: 43 | return self.worker.unavailable_timeout 44 | 45 | def has_time_elapsed(self): 46 | elapsed = time.time() - self.last_status_time 47 | return elapsed > self._timeout() 48 | 49 | def payload(self): 50 | if self.available: 51 | return self.worker.available_payload 52 | else: 53 | return self.worker.unavailable_payload 54 | 55 | def generate_messages(self, device): 56 | messages = [] 57 | if not self.message_sent and self.has_time_elapsed(): 58 | self.message_sent = True 59 | messages.append( 60 | MqttMessage( 61 | topic=self.worker.format_topic("presence/{}".format(self.name)), 62 | payload=self.payload(), 63 | ) 64 | ) 65 | if self.available: 66 | messages.append( 67 | MqttMessage( 68 | topic=self.worker.format_topic( 69 | "presence/{}/rssi".format(self.name) 70 | ), 71 | payload=device.rssi, 72 | ) 73 | ) 74 | return messages 75 | 76 | 77 | class BlescanmultiWorker(BaseWorker): 78 | # Default values 79 | devices = {} 80 | # Payload that should be send when device is available 81 | available_payload = "home" # type: str 82 | # Payload that should be send when device is unavailable 83 | unavailable_payload = "not_home" # type: str 84 | # After what time (in seconds) we should inform that device is available (default: 0 seconds) 85 | available_timeout = 0 # type: float 86 | # After what time (in seconds) we should inform that device is unavailable (default: 60 seconds) 87 | unavailable_timeout = 60 # type: float 88 | scan_timeout = 10.0 # type: float 89 | scan_passive = True # type: str or bool 90 | 91 | def __init__(self, *args, **kwargs): 92 | from bluepy.btle import Scanner, DefaultDelegate 93 | 94 | class ScanDelegate(DefaultDelegate): 95 | def __init__(self): 96 | DefaultDelegate.__init__(self) 97 | 98 | def handleDiscovery(self, dev, isNewDev, isNewData): 99 | if isNewDev: 100 | _LOGGER.debug("Discovered new device: %s" % dev.addr) 101 | 102 | super(BlescanmultiWorker, self).__init__(*args, **kwargs) 103 | self.scanner = Scanner().withDelegate(ScanDelegate()) 104 | self.last_status = [ 105 | BleDeviceStatus(self, mac, name) for name, mac in self.devices.items() 106 | ] 107 | _LOGGER.info("Adding %d %s devices", len(self.devices), repr(self)) 108 | 109 | def status_update(self): 110 | from bluepy import btle 111 | 112 | _LOGGER.info("Updating %d %s devices", len(self.devices), repr(self)) 113 | 114 | ret = [] 115 | 116 | try: 117 | devices = self.scanner.scan( 118 | float(self.scan_timeout), passive=booleanize(self.scan_passive) 119 | ) 120 | mac_addresses = {device.addr: device for device in devices} 121 | 122 | for status in self.last_status: 123 | device = mac_addresses.get(status.mac, None) 124 | status.set_status(device is not None) 125 | ret += status.generate_messages(device) 126 | 127 | except btle.BTLEException as e: 128 | logger.log_exception( 129 | _LOGGER, 130 | "Error during update (%s)", 131 | repr(self), 132 | type(e).__name__, 133 | suppress=True, 134 | ) 135 | 136 | return ret 137 | -------------------------------------------------------------------------------- /workers/ibbq.py: -------------------------------------------------------------------------------- 1 | """ 2 | worker for inkbird ibbq and other equivalent cooking/BBQ thermometers. 3 | 4 | Thermometer sends every ~2sec the current temperature. 5 | 6 | """ 7 | import struct 8 | 9 | from mqtt import MqttMessage 10 | from workers.base import BaseWorker 11 | import logger 12 | import json 13 | 14 | _LOGGER = logger.get(__name__) 15 | 16 | REQUIREMENTS = ["bluepy"] 17 | 18 | 19 | class IbbqWorker(BaseWorker): 20 | def _setup(self): 21 | _LOGGER.info("Adding %d %s devices", len(self.devices), repr(self)) 22 | for name, mac in self.devices.items(): 23 | _LOGGER.info("Adding %s device '%s' (%s)", repr(self), name, mac) 24 | self.devices[name] = ibbqThermometer(mac, timeout=self.command_timeout) 25 | 26 | def format_static_topic(self, *args): 27 | return "/".join([self.topic_prefix, *args]) 28 | 29 | def __repr__(self): 30 | return self.__module__.split(".")[-1] 31 | 32 | def status_update(self): 33 | for name, ibbq in self.devices.items(): 34 | ret = dict() 35 | value = list() 36 | if not ibbq.connected: 37 | ibbq.device = ibbq.connect() 38 | ibbq.subscribe() 39 | bat, value = None, value 40 | else: 41 | bat, value = ibbq.update() 42 | n = 0 43 | ret["available"] = ibbq.connected 44 | ret["battery_level"] = bat 45 | for i in value: 46 | n += 1 47 | ret["Temp{}".format(n)] = i 48 | return [ 49 | MqttMessage( 50 | topic=self.format_static_topic(name), payload=json.dumps(ret) 51 | ) 52 | ] 53 | 54 | 55 | class ibbqThermometer: 56 | SettingResult = "fff1" 57 | AccountAndVerify = "fff2" 58 | RealTimeData = "fff4" 59 | SettingData = "fff5" 60 | Notify = b"\x01\x00" 61 | realTimeDataEnable = bytearray([0x0B, 0x01, 0x00, 0x00, 0x00, 0x00]) 62 | batteryLevel = bytearray([0x08, 0x24, 0x00, 0x00, 0x00, 0x00]) 63 | KEY = bytearray( 64 | [ 65 | 0x21, 66 | 0x07, 67 | 0x06, 68 | 0x05, 69 | 0x04, 70 | 0x03, 71 | 0x02, 72 | 0x01, 73 | 0xB8, 74 | 0x22, 75 | 0x00, 76 | 0x00, 77 | 0x00, 78 | 0x00, 79 | 0x00, 80 | ] 81 | ) 82 | 83 | def getBattery(self): 84 | self.Setting_uuid.write(self.batteryLevel) 85 | 86 | def connect(self, timeout=5): 87 | from bluepy import btle 88 | 89 | try: 90 | device = btle.Peripheral(self.mac) 91 | _LOGGER.debug("%s connected ", self.mac) 92 | return device 93 | except btle.BTLEDisconnectError as er: 94 | _LOGGER.debug("failed connect %s", er) 95 | 96 | def __init__(self, mac, timeout=5): 97 | self.cnt = 0 98 | self.batteryPct = 0 99 | self.timeout = timeout 100 | self.mac = mac 101 | self.values = list() 102 | self.device = self.connect() 103 | self.offline = 0 104 | if not self.device: 105 | return 106 | self.device = self.subscribe() 107 | 108 | @property 109 | def connected(self): 110 | return bool(self.device) 111 | 112 | def subscribe(self, timeout=5): 113 | from bluepy import btle 114 | 115 | class MyDelegate(btle.DefaultDelegate): 116 | def __init__(self, caller): 117 | btle.DefaultDelegate.__init__(self) 118 | self.caller = caller 119 | _LOGGER.debug("init mydelegate") 120 | 121 | def handleNotification(self, cHandle, data): 122 | batMin = 0.95 123 | batMax = 1.5 124 | result = list() 125 | # safe = data 126 | if cHandle == 37: 127 | if data[0] == 0x24: 128 | currentV = struct.unpack(" 0: 136 | v, data = data[0:2], data[2:] 137 | result.append(struct.unpack(" 5: 186 | self.cnt = 0 187 | self.getBattery() 188 | while self.device.waitForNotifications(1): 189 | pass 190 | if self.values: 191 | self.offline = 0 192 | else: 193 | _LOGGER.debug("%s is silent", self.mac) 194 | if self.offline > 3: 195 | try: 196 | self.device.disconnect() 197 | except btle.BTLEInternalError as e: 198 | _LOGGER.debug("%s", e) 199 | self.device = None 200 | _LOGGER.debug("%s reconnect", self.mac) 201 | else: 202 | self.offline += 1 203 | except btle.BTLEDisconnectError as e: 204 | _LOGGER.debug("%s", e) 205 | self.device = None 206 | finally: 207 | return (self.batteryPct, self.values) 208 | 209 | 210 | -------------------------------------------------------------------------------- /workers/lightstring.py: -------------------------------------------------------------------------------- 1 | from builtins import staticmethod 2 | import logging 3 | 4 | from mqtt import MqttMessage 5 | 6 | from workers.base import BaseWorker 7 | import logger 8 | 9 | REQUIREMENTS = ["bluepy"] 10 | _LOGGER = logger.get(__name__) 11 | 12 | STATE_ON = "ON" 13 | STATE_OFF = "OFF" 14 | 15 | # reversed from com.scinan.novolink.lightstring apk 16 | # https://play.google.com/store/apps/details?id=com.scinan.novolink.lightstring 17 | 18 | # write characteristics handle 19 | HAND = 0x0025 20 | 21 | # hex bytecodes for various operations 22 | HEX_STATE_ON = "01010101" 23 | HEX_STATE_OFF = "01010100" 24 | HEX_CONF_PREFIX = "05010203" 25 | HEX_ENUM_STATE = "000003" 26 | HEX_ENUM_CONF = "02000000" 27 | 28 | class LightstringWorker(BaseWorker): 29 | def _setup(self): 30 | 31 | _LOGGER.info("Adding %d %s devices", len(self.devices), repr(self)) 32 | for name, mac in self.devices.items(): 33 | _LOGGER.info("Adding %s device '%s' (%s)", repr(self), name, mac) 34 | self.devices[name] = {"lightstring": None, "state": STATE_OFF, "conf": 0, "mac": mac} 35 | 36 | def format_state_topic(self, *args): 37 | return "/".join([self.topic_prefix, *args, "state"]) 38 | 39 | def format_conf_topic(self, *args): 40 | return "/".join([self.topic_prefix, *args, "conf"]) 41 | 42 | def status_update(self): 43 | from bluepy import btle 44 | import binascii 45 | from bluepy.btle import Peripheral 46 | 47 | class MyDelegate(btle.DefaultDelegate): 48 | def __init__(self): 49 | self.state = '' 50 | btle.DefaultDelegate.__init__(self) 51 | def handleNotification(self, cHandle, data): 52 | try: 53 | if data[3] in (0, 3): 54 | self.state = "OFF" 55 | else: 56 | self.state = "ON" 57 | except: 58 | self.state = -1 59 | 60 | class ConfDelegate(btle.DefaultDelegate): 61 | def __init__(self): 62 | self.conf = 0 63 | btle.DefaultDelegate.__init__(self) 64 | def handleNotification(self, cHandle, data): 65 | try: 66 | self.conf = int(data[17]) 67 | except: 68 | self.conf = -1 69 | 70 | delegate = MyDelegate() 71 | cdelegate = ConfDelegate() 72 | ret = [] 73 | _LOGGER.debug("Updating %d %s devices", len(self.devices), repr(self)) 74 | for name, lightstring in self.devices.items(): 75 | _LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, lightstring["mac"]) 76 | try: 77 | lightstring["lightstring"] = Peripheral(lightstring["mac"]) 78 | lightstring["lightstring"].setDelegate(delegate) 79 | lightstring["lightstring"].writeCharacteristic(HAND, binascii.a2b_hex(HEX_ENUM_STATE)) 80 | lightstring["lightstring"].waitForNotifications(1.0) 81 | lightstring["lightstring"].disconnect() 82 | lightstring["lightstring"].connect(lightstring["mac"]) 83 | lightstring["lightstring"].setDelegate(cdelegate) 84 | lightstring["lightstring"].writeCharacteristic(HAND, binascii.a2b_hex(HEX_ENUM_CONF)) 85 | lightstring["lightstring"].waitForNotifications(1.0) 86 | lightstring["lightstring"].disconnect() 87 | if delegate.state != -1: 88 | lightstring["state"] = delegate.state 89 | ret += self.update_device_state(name, lightstring["state"]) 90 | if cdelegate.conf != -1: 91 | lightstring["conf"] = cdelegate.conf 92 | ret += self.update_device_conf(name, lightstring["conf"]) 93 | except btle.BTLEException as e: 94 | logger.log_exception( 95 | _LOGGER, 96 | "Error during update of %s device '%s' (%s): %s", 97 | repr(self), 98 | name, 99 | lightstring["mac"], 100 | type(e).__name__, 101 | suppress=True, 102 | ) 103 | return ret 104 | 105 | def on_command(self, topic, value): 106 | from bluepy import btle 107 | import binascii 108 | from bluepy.btle import Peripheral 109 | 110 | _, _, device_name, _ = topic.split("/") 111 | 112 | lightstring = self.devices[device_name] 113 | 114 | value = value.decode("utf-8") 115 | 116 | # It needs to be on separate if because first if can change method 117 | 118 | _LOGGER.debug( 119 | "Setting %s on %s device '%s' (%s)", 120 | value, 121 | repr(self), 122 | device_name, 123 | lightstring["mac"], 124 | ) 125 | success = False 126 | while not success: 127 | try: 128 | lightstring["lightstring"] = Peripheral(lightstring["mac"]) 129 | if value == STATE_ON: 130 | lightstring["lightstring"].writeCharacteristic(HAND, binascii.a2b_hex(HEX_STATE_ON)) 131 | elif value == STATE_OFF: 132 | lightstring["lightstring"].writeCharacteristic(HAND, binascii.a2b_hex(HEX_STATE_OFF)) 133 | else: 134 | lightstring["lightstring"].writeCharacteristic(HAND, binascii.a2b_hex(HEX_CONF_PREFIX)+bytes([int(value)])) 135 | lightstring["lightstring"].disconnect() 136 | success = True 137 | except btle.BTLEException as e: 138 | logger.log_exception( 139 | _LOGGER, 140 | "Error setting %s on %s device '%s' (%s): %s", 141 | value, 142 | repr(self), 143 | device_name, 144 | lightstring["mac"], 145 | type(e).__name__, 146 | ) 147 | success = True 148 | 149 | try: 150 | if value in (STATE_ON, STATE_OFF): 151 | return self.update_device_state(device_name, value) 152 | else: 153 | return self.update_device_conf(device_name, value) 154 | except btle.BTLEException as e: 155 | logger.log_exception( 156 | _LOGGER, 157 | "Error during update of %s device '%s' (%s): %s", 158 | repr(self), 159 | device_name, 160 | lightstring["mac"], 161 | type(e).__name__, 162 | suppress=True, 163 | ) 164 | return [] 165 | 166 | def update_device_state(self, name, value): 167 | return [MqttMessage(topic=self.format_state_topic(name), payload=value)] 168 | 169 | def update_device_conf(self, name, value): 170 | return [MqttMessage(topic=self.format_conf_topic(name), payload=value)] 171 | -------------------------------------------------------------------------------- /workers/linakdesk.py: -------------------------------------------------------------------------------- 1 | from interruptingcow import timeout 2 | 3 | import logger 4 | from exceptions import DeviceTimeoutError 5 | from mqtt import MqttMessage 6 | from workers.base import BaseWorker 7 | 8 | _LOGGER = logger.get(__name__) 9 | 10 | REQUIREMENTS = [ 11 | "git+https://github.com/zewelor/linak_bt_desk.git@aa9412f98b3044be34c70e89d02721e6813ea731#egg=linakdpgbt" 12 | ] 13 | 14 | 15 | class LinakdeskWorker(BaseWorker): 16 | 17 | SCAN_TIMEOUT = 20 18 | 19 | def _setup(self): 20 | from linak_dpg_bt import LinakDesk 21 | 22 | self.desk = LinakDesk(self.mac) 23 | 24 | def status_update(self): 25 | return [ 26 | MqttMessage( 27 | topic=self.format_topic("height/cm"), payload=self._get_height() 28 | ) 29 | ] 30 | 31 | def _get_height(self): 32 | from bluepy import btle 33 | 34 | with timeout( 35 | self.SCAN_TIMEOUT, 36 | exception=DeviceTimeoutError( 37 | "Retrieving the height from {} device {} timed out after {} seconds".format( 38 | repr(self), self.mac, self.SCAN_TIMEOUT 39 | ) 40 | ), 41 | ): 42 | try: 43 | self.desk.read_dpg_data() 44 | return self.desk.current_height_with_offset.cm 45 | except btle.BTLEException as e: 46 | logger.log_exception( 47 | _LOGGER, 48 | "Error during update of linak desk '%s' (%s): %s", 49 | repr(self), 50 | self.mac, 51 | type(e).__name__, 52 | suppress=True, 53 | ) 54 | raise DeviceTimeoutError 55 | -------------------------------------------------------------------------------- /workers/lywsd02.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logger 3 | 4 | from contextlib import contextmanager 5 | from struct import unpack 6 | 7 | from mqtt import MqttMessage 8 | from workers.base import BaseWorker 9 | 10 | _LOGGER = logger.get(__name__) 11 | 12 | REQUIREMENTS = ["bluepy"] 13 | 14 | 15 | class Lywsd02Worker(BaseWorker): 16 | def _setup(self): 17 | _LOGGER.info("Adding %d %s devices", len(self.devices), repr(self)) 18 | for name, mac in self.devices.items(): 19 | _LOGGER.info("Adding %s device '%s' (%s)", repr(self), name, mac) 20 | self.devices[name] = Lywsd02(mac, timeout=self.command_timeout) 21 | 22 | def status_update(self): 23 | from bluepy import btle 24 | 25 | for name, lywsd02 in self.devices.items(): 26 | try: 27 | ret = lywsd02.readAll() 28 | except btle.BTLEDisconnectError as e: 29 | self.log_connect_exception(_LOGGER, name, e) 30 | except btle.BTLEException as e: 31 | self.log_unspecified_exception(_LOGGER, name, e) 32 | else: 33 | yield [MqttMessage(topic=self.format_topic(name), payload=json.dumps(ret))] 34 | 35 | 36 | class Lywsd02: 37 | UUID_DATA = "ebe0ccc1-7a0a-4b0c-8a1a-6ff2997da3a6" 38 | UUID_BATT = "ebe0ccc4-7a0a-4b0c-8a1a-6ff2997da3a6" 39 | 40 | def __init__(self, mac, timeout=30): 41 | self.mac = mac 42 | self.timeout = timeout 43 | 44 | self._temperature = None 45 | self._humidity = None 46 | self._battery = None 47 | 48 | @contextmanager 49 | def connected(self): 50 | from bluepy import btle 51 | 52 | _LOGGER.debug("%s connected ", self.mac) 53 | device = btle.Peripheral() 54 | device.connect(self.mac) 55 | yield device 56 | device.disconnect() 57 | 58 | def readAll(self): 59 | with self.connected() as device: 60 | temperature, humidity = self.getData(device) 61 | battery = self.getBattery(device) 62 | 63 | _LOGGER.debug("successfully read %f, %d, %d", temperature, humidity, battery) 64 | 65 | return { 66 | "temperature": temperature, 67 | "humidity": humidity, 68 | "battery": battery, 69 | } 70 | 71 | def getData(self, device): 72 | self.subscribe(device, self.UUID_DATA) 73 | while True: 74 | if device.waitForNotifications(self.timeout): 75 | break 76 | return self._temperature, self._humidity 77 | 78 | def getBattery(self, device): 79 | c = device.getCharacteristics(uuid=self.UUID_BATT)[0] 80 | return ord(c.read()) 81 | 82 | def subscribe(self, device, uuid): 83 | device.setDelegate(self) 84 | c = device.getCharacteristics(uuid=uuid)[0] 85 | d = c.getDescriptors(forUUID=0x2902)[0] 86 | 87 | d.write(0x01.to_bytes(2, byteorder="little"), withResponse=True) 88 | 89 | def processSensorsData(self, data): 90 | self._temperature = unpack("H", data[:2])[0] / 100 91 | self._humidity = data[2] 92 | 93 | def handleNotification(self, handle, data): 94 | # 0x4b is sensors data 95 | if handle == 0x4b: 96 | self.processSensorsData(data) 97 | -------------------------------------------------------------------------------- /workers/lywsd03mmc.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logger 3 | 4 | from contextlib import contextmanager 5 | 6 | from mqtt import MqttMessage 7 | from workers.base import BaseWorker 8 | 9 | _LOGGER = logger.get(__name__) 10 | 11 | REQUIREMENTS = ["bluepy"] 12 | 13 | class Lywsd03MmcWorker(BaseWorker): 14 | def _setup(self): 15 | _LOGGER.info("Adding %d %s devices", len(self.devices), repr(self)) 16 | 17 | for name, mac in self.devices.items(): 18 | _LOGGER.info("Adding %s device '%s' (%s)", repr(self), name, mac) 19 | self.devices[name] = lywsd03mmc(mac, command_timeout=self.command_timeout, passive=self.passive) 20 | 21 | def find_device(self, mac): 22 | for name, device in self.devices.items(): 23 | if device.mac == mac: 24 | return device 25 | return 26 | 27 | def status_update(self): 28 | from bluepy import btle 29 | 30 | if self.passive: 31 | scanner = btle.Scanner() 32 | results = scanner.scan(self.scan_timeout if hasattr(self, 'scan_timeout') else 20.0, passive=True) 33 | 34 | for res in results: 35 | device = self.find_device(res.addr) 36 | if device: 37 | for (adtype, desc, value) in res.getScanData(): 38 | if ("1a18" in value): 39 | _LOGGER.debug("%s - received scan data %s", res.addr, value) 40 | device.processScanValue(value) 41 | 42 | for name, lywsd03mmc in self.devices.items(): 43 | try: 44 | ret = lywsd03mmc.readAll() 45 | except btle.BTLEDisconnectError as e: 46 | self.log_connect_exception(_LOGGER, name, e) 47 | except btle.BTLEException as e: 48 | self.log_unspecified_exception(_LOGGER, name, e) 49 | else: 50 | yield [MqttMessage(topic=self.format_topic(name), payload=json.dumps(ret))] 51 | 52 | 53 | class lywsd03mmc: 54 | def __init__(self, mac, command_timeout=30, passive=False): 55 | self.mac = mac 56 | self.passive = passive 57 | self.command_timeout = command_timeout 58 | 59 | self._temperature = None 60 | self._humidity = None 61 | self._battery = None 62 | 63 | @contextmanager 64 | def connected(self): 65 | from bluepy import btle 66 | 67 | _LOGGER.debug("%s - connected ", self.mac) 68 | device = btle.Peripheral() 69 | device.connect(self.mac) 70 | device.writeCharacteristic(0x0038, b'\x01\x00', True) 71 | device.writeCharacteristic(0x0046, b'\xf4\x01\x00', True) 72 | yield device 73 | 74 | def readAll(self): 75 | if self.passive: 76 | temperature = self.getTemperature() 77 | humidity = self.getHumidity() 78 | battery = self.getBattery() 79 | else: 80 | with self.connected() as device: 81 | self.getData(device) 82 | temperature = self.getTemperature() 83 | humidity = self.getHumidity() 84 | battery = self.getBattery() 85 | 86 | if temperature and humidity and battery: 87 | _LOGGER.debug("%s - found values %f, %d, %d", self.mac, temperature, humidity, battery) 88 | else: 89 | _LOGGER.debug("%s - no data received", self.mac) 90 | 91 | return { 92 | "temperature": temperature, 93 | "humidity": humidity, 94 | "battery": battery, 95 | } 96 | 97 | def getData(self, device): 98 | self.subscribe(device) 99 | while True: 100 | if device.waitForNotifications(self.command_timeout): 101 | break 102 | return self._temperature, self._humidity, self._battery 103 | 104 | def getTemperature(self): 105 | return self._temperature; 106 | 107 | def getHumidity(self): 108 | return self._humidity; 109 | 110 | def getBattery(self): 111 | return self._battery; 112 | 113 | def subscribe(self, device): 114 | device.setDelegate(self) 115 | 116 | def processScanValue(self, data): 117 | temperature = int(data[16:20], 16) / 10 118 | humidity = int(data[20:22], 16) 119 | battery = int(data[22:24], 16) 120 | 121 | self._temperature = round(temperature, 1) 122 | self._humidity = round(humidity) 123 | self._battery = round(battery, 4) 124 | 125 | def handleNotification(self, handle, data): 126 | temperature = int.from_bytes(data[0:2], byteorder='little', signed=True) / 100 127 | humidity = int.from_bytes(data[2:3], byteorder='little') 128 | battery = int.from_bytes(data[3:5], byteorder='little') / 1000 129 | 130 | self._temperature = round(temperature, 1) 131 | self._humidity = round(humidity) 132 | self._battery = round(battery, 4) 133 | -------------------------------------------------------------------------------- /workers/lywsd03mmc_homeassistant.py: -------------------------------------------------------------------------------- 1 | from exceptions import DeviceTimeoutError 2 | from mqtt import MqttMessage, MqttConfigMessage 3 | 4 | from interruptingcow import timeout 5 | from workers.base import BaseWorker 6 | from workers.lywsd03mmc import lywsd03mmc 7 | import logger 8 | import json 9 | import time 10 | from contextlib import contextmanager 11 | 12 | REQUIREMENTS = ["bluepy"] 13 | 14 | ATTR_BATTERY = "battery" 15 | ATTR_LOW_BATTERY = 'low_battery' 16 | 17 | monitoredAttrs = ["temperature", "humidity", ATTR_BATTERY] 18 | _LOGGER = logger.get(__name__) 19 | 20 | class Lywsd03Mmc_HomeassistantWorker(BaseWorker): 21 | """ 22 | This worker for the Lywsd03Mmc creates the sensor entries in 23 | MQTT for Home Assistant. It also creates a binary sensor for 24 | low batteries. It supports connection retries. 25 | """ 26 | def _setup(self): 27 | _LOGGER.info("Adding %d %s devices", len(self.devices), repr(self)) 28 | for name, mac in self.devices.items(): 29 | _LOGGER.debug("Adding %s device '%s' (%s)", repr(self), name, mac) 30 | self.devices[name] = lywsd03mmc(mac, command_timeout=self.command_timeout, passive=self.passive) 31 | 32 | def config(self, availability_topic): 33 | ret = [] 34 | for name, device in self.devices.items(): 35 | ret += self.config_device(name, device.mac) 36 | return ret 37 | 38 | def find_device(self, mac): 39 | for name, device in self.devices.items(): 40 | if device.mac == mac: 41 | return device 42 | return 43 | 44 | def config_device(self, name, mac): 45 | ret = [] 46 | device = { 47 | "identifiers": [mac, self.format_discovery_id(mac, name)], 48 | "manufacturer": "Xiaomi", 49 | "model": "Mijia Lywsd03Mmc", 50 | "name": self.format_discovery_name(name), 51 | } 52 | 53 | for attr in monitoredAttrs: 54 | payload = { 55 | "unique_id": self.format_discovery_id(mac, name, attr), 56 | "state_topic": self.format_prefixed_topic(name, attr), 57 | "name": self.format_discovery_name(name, attr), 58 | "device": device, 59 | } 60 | 61 | if attr == "humidity": 62 | payload.update({"icon": "mdi:water", "unit_of_measurement": "%"}) 63 | elif attr == "temperature": 64 | payload.update( 65 | {"device_class": "temperature", "unit_of_measurement": "°C"} 66 | ) 67 | elif attr == ATTR_BATTERY: 68 | payload.update({"device_class": "battery", "unit_of_measurement": "V"}) 69 | 70 | ret.append( 71 | MqttConfigMessage( 72 | MqttConfigMessage.SENSOR, 73 | self.format_discovery_topic(mac, name, attr), 74 | payload=payload, 75 | ) 76 | ) 77 | 78 | ret.append( 79 | MqttConfigMessage( 80 | MqttConfigMessage.BINARY_SENSOR, 81 | self.format_discovery_topic(mac, name, ATTR_LOW_BATTERY), 82 | payload={ 83 | "unique_id": self.format_discovery_id(mac, name, ATTR_LOW_BATTERY), 84 | "state_topic": self.format_prefixed_topic(name, ATTR_LOW_BATTERY), 85 | "name": self.format_discovery_name(name, ATTR_LOW_BATTERY), 86 | "device": device, 87 | "device_class": "battery", 88 | }, 89 | ) 90 | ) 91 | 92 | return ret 93 | 94 | def status_update(self): 95 | from bluepy import btle 96 | _LOGGER.info("Updating %d %s devices", len(self.devices), repr(self)) 97 | 98 | if self.passive: 99 | scanner = btle.Scanner() 100 | results = scanner.scan(self.scan_timeout if hasattr(self, 'scan_timeout') else 20.0, passive=True) 101 | 102 | for res in results: 103 | device = self.find_device(res.addr) 104 | if device: 105 | for (adtype, desc, value) in res.getScanData(): 106 | if ("1a18" in value): 107 | _LOGGER.debug("%s - received scan data %s", res.addr, value) 108 | device.processScanValue(value) 109 | 110 | for name, device in self.devices.items(): 111 | _LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, device.mac) 112 | # from btlewrap import BluetoothBackendException 113 | 114 | try: 115 | with timeout(self.command_timeout, exception=DeviceTimeoutError): 116 | yield self.update_device_state(name, device) 117 | except btle.BTLEException as e: 118 | logger.log_exception( 119 | _LOGGER, 120 | "Error during update of %s device '%s' (%s): %s", 121 | repr(self), 122 | name, 123 | device.mac, 124 | type(e).__name__, 125 | suppress=True, 126 | ) 127 | except TypeError: 128 | logger.log_exception( 129 | _LOGGER, 130 | "Data error during update of %s device '%s' (%s)", 131 | repr(self), 132 | name, 133 | device.mac, 134 | suppress=True, 135 | ) 136 | except DeviceTimeoutError: 137 | logger.log_exception( 138 | _LOGGER, 139 | "Time out during update of %s device '%s' (%s)", 140 | repr(self), 141 | name, 142 | device.mac, 143 | suppress=True, 144 | ) 145 | 146 | def update_device_state(self, name, device): 147 | ret = [] 148 | if device.readAll() is None : 149 | return ret 150 | for attr in monitoredAttrs: 151 | 152 | attrValue = None 153 | if attr == "humidity": 154 | attrValue = device.getHumidity() 155 | elif attr == "temperature": 156 | attrValue = device.getTemperature() 157 | elif attr == ATTR_BATTERY: 158 | attrValue = device.getBattery() 159 | 160 | ret.append( 161 | MqttMessage( 162 | topic=self.format_topic(name, attr), 163 | payload=attrValue, 164 | ) 165 | ) 166 | 167 | # Low battery binary sensor 168 | ret.append( 169 | MqttMessage( 170 | topic=self.format_topic(name, ATTR_LOW_BATTERY), 171 | payload=self.true_false_to_ha_on_off(device.getBattery() < 3), 172 | ) 173 | ) 174 | 175 | return ret 176 | -------------------------------------------------------------------------------- /workers/miflora.py: -------------------------------------------------------------------------------- 1 | from const import DEFAULT_PER_DEVICE_TIMEOUT 2 | from exceptions import DeviceTimeoutError 3 | from mqtt import MqttMessage, MqttConfigMessage 4 | 5 | from interruptingcow import timeout 6 | from workers.base import BaseWorker, retry 7 | import logger 8 | 9 | REQUIREMENTS = [ 10 | "bluepy", 11 | "miflora", 12 | ] 13 | 14 | ATTR_BATTERY = "battery" 15 | ATTR_LOW_BATTERY = 'low_battery' 16 | 17 | monitoredAttrs = ["temperature", "moisture", "light", "conductivity", ATTR_BATTERY] 18 | _LOGGER = logger.get(__name__) 19 | 20 | 21 | class MifloraWorker(BaseWorker): 22 | per_device_timeout = DEFAULT_PER_DEVICE_TIMEOUT # type: int 23 | 24 | def _setup(self): 25 | from miflora.miflora_poller import MiFloraPoller 26 | from btlewrap.bluepy import BluepyBackend 27 | 28 | _LOGGER.info("Adding %d %s devices", len(self.devices), repr(self)) 29 | for name, mac in self.devices.items(): 30 | _LOGGER.debug("Adding %s device '%s' (%s)", repr(self), name, mac) 31 | self.devices[name] = { 32 | "mac": mac, 33 | "poller": MiFloraPoller( 34 | mac, BluepyBackend, adapter=getattr(self, 'adapter', 'hci0')), 35 | } 36 | 37 | def config(self, availability_topic): 38 | ret = [] 39 | for name, data in self.devices.items(): 40 | ret += self.config_device(name, data["mac"]) 41 | return ret 42 | 43 | def config_device(self, name, mac): 44 | ret = [] 45 | device = { 46 | "identifiers": [mac, self.format_discovery_id(mac, name)], 47 | "manufacturer": "Xiaomi", 48 | "model": "MiFlora", 49 | "name": self.format_discovery_name(name), 50 | } 51 | 52 | for attr in monitoredAttrs: 53 | payload = { 54 | "unique_id": self.format_discovery_id(mac, name, attr), 55 | "state_topic": self.format_prefixed_topic(name, attr), 56 | "name": self.format_discovery_name(name, attr), 57 | "device": device, 58 | } 59 | 60 | if attr == "light": 61 | payload.update( 62 | { 63 | "unique_id": self.format_discovery_id(mac, name, "illuminance"), 64 | "device_class": "illuminance", 65 | "unit_of_measurement": "lux", 66 | } 67 | ) 68 | elif attr == "moisture": 69 | payload.update({"icon": "mdi:water", "unit_of_measurement": "%"}) 70 | elif attr == "conductivity": 71 | payload.update({"icon": "mdi:leaf", "unit_of_measurement": "µS/cm"}) 72 | elif attr == "temperature": 73 | payload.update( 74 | {"device_class": "temperature", "unit_of_measurement": "°C"} 75 | ) 76 | elif attr == ATTR_BATTERY: 77 | payload.update({"device_class": "battery", "unit_of_measurement": "%"}) 78 | 79 | ret.append( 80 | MqttConfigMessage( 81 | MqttConfigMessage.SENSOR, 82 | self.format_discovery_topic(mac, name, attr), 83 | payload=payload, 84 | ) 85 | ) 86 | 87 | ret.append( 88 | MqttConfigMessage( 89 | MqttConfigMessage.BINARY_SENSOR, 90 | self.format_discovery_topic(mac, name, ATTR_LOW_BATTERY), 91 | payload={ 92 | "unique_id": self.format_discovery_id(mac, name, ATTR_LOW_BATTERY), 93 | "state_topic": self.format_prefixed_topic(name, ATTR_LOW_BATTERY), 94 | "name": self.format_discovery_name(name, ATTR_LOW_BATTERY), 95 | "device": device, 96 | "device_class": "battery", 97 | }, 98 | ) 99 | ) 100 | 101 | return ret 102 | 103 | def status_update(self): 104 | _LOGGER.info("Updating %d %s devices", len(self.devices), repr(self)) 105 | 106 | for name, data in self.devices.items(): 107 | _LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, data["mac"]) 108 | from btlewrap import BluetoothBackendException 109 | 110 | try: 111 | with timeout(self.per_device_timeout, exception=DeviceTimeoutError): 112 | yield retry(self.update_device_state, retries=self.update_retries, exception_type=BluetoothBackendException)(name, data["poller"]) 113 | except BluetoothBackendException as e: 114 | logger.log_exception( 115 | _LOGGER, 116 | "Error during update of %s device '%s' (%s): %s", 117 | repr(self), 118 | name, 119 | data["mac"], 120 | type(e).__name__, 121 | suppress=True, 122 | ) 123 | except DeviceTimeoutError: 124 | logger.log_exception( 125 | _LOGGER, 126 | "Time out during update of %s device '%s' (%s)", 127 | repr(self), 128 | name, 129 | data["mac"], 130 | suppress=True, 131 | ) 132 | 133 | def update_device_state(self, name, poller): 134 | ret = [] 135 | poller.clear_cache() 136 | for attr in monitoredAttrs: 137 | payload = poller.parameter_value(attr) 138 | 139 | # We sometimes see light values of over 400 million. This 140 | # probably comes from a sensor error or maybe the miflora 141 | # library not understanding the protocol completely. 142 | # 143 | # In any case, direct sunlight is up to 100 thousand lux, 144 | # so anything above that is suspicious. Lets cap our value 145 | # at 1 million lux, an order of magnitude more than we ever 146 | # expect. 147 | if (attr == "light") and (payload > 1_000_000): 148 | continue 149 | 150 | ret.append( 151 | MqttMessage( 152 | topic=self.format_topic(name, attr), 153 | payload=payload, 154 | ) 155 | ) 156 | 157 | # Low battery binary sensor 158 | ret.append( 159 | MqttMessage( 160 | topic=self.format_topic(name, ATTR_LOW_BATTERY), 161 | payload=self.true_false_to_ha_on_off(poller.parameter_value(ATTR_BATTERY) < 10), 162 | ) 163 | ) 164 | 165 | return ret 166 | -------------------------------------------------------------------------------- /workers/miscale.py: -------------------------------------------------------------------------------- 1 | from math import floor 2 | 3 | from datetime import datetime 4 | import time 5 | from interruptingcow import timeout 6 | 7 | from exceptions import DeviceTimeoutError 8 | from mqtt import MqttMessage 9 | from workers.base import BaseWorker 10 | 11 | REQUIREMENTS = ["bluepy"] 12 | 13 | 14 | # Bluepy might need special settings 15 | # sudo setcap 'cap_net_raw,cap_net_admin+eip' /usr/local/lib/python3.6/dist-packages/bluepy/bluepy-helper 16 | 17 | 18 | class MiscaleWorker(BaseWorker): 19 | 20 | SCAN_TIMEOUT = 5 21 | 22 | def getAge(self, d1): 23 | d1 = datetime.strptime(str(d1), "%Y-%m-%d") 24 | d2 = datetime.strptime(datetime.today().strftime("%Y-%m-%d"), "%Y-%m-%d") 25 | return abs((d2 - d1).days) / 365 26 | 27 | def status_update(self): 28 | results = self._get_data() 29 | 30 | messages = [ 31 | MqttMessage( 32 | topic=self.format_topic("weight/" + results.unit), 33 | payload=results.weight, 34 | ) 35 | ] 36 | if results.impedance: 37 | messages.append( 38 | MqttMessage( 39 | topic=self.format_topic("impedance"), payload=results.impedance 40 | ) 41 | ) 42 | if results.midatetime: 43 | messages.append( 44 | MqttMessage( 45 | topic=self.format_topic("midatetime"), payload=results.midatetime 46 | ) 47 | ) 48 | 49 | if hasattr(self, "users"): 50 | for key, item in self.users.items(): 51 | if ( 52 | item["weight_template"]["min"] 53 | <= results.weight 54 | <= item["weight_template"]["max"] 55 | ): 56 | user = key 57 | sex = item["sex"] 58 | height = item["height"] 59 | age = self.getAge(item["dob"]) 60 | 61 | lib = bodyMetrics(results.weight, results.unit, height, age, sex, results.impedance) 62 | metrics = { 63 | "weight": float("{:.2f}".format(results.weight)), 64 | "bmi": float("{:.2f}".format(lib.getBMI())), 65 | "basal_metabolism": float("{:.2f}".format(lib.getBMR())), 66 | "visceral_fat": float("{:.2f}".format(lib.getVisceralFat())), 67 | "user": user, 68 | } 69 | 70 | if lib.is_impedance_value_valid(): 71 | metrics["impedance"] = lib.impedance 72 | metrics["lean_body_mass"] = float( 73 | "{:.2f}".format(lib.getLBMCoefficient()) 74 | ) 75 | metrics["body_fat"] = float( 76 | "{:.2f}".format(lib.getFatPercentage()) 77 | ) 78 | metrics["water"] = float( 79 | "{:.2f}".format(lib.getWaterPercentage()) 80 | ) 81 | metrics["bone_mass"] = float("{:.2f}".format(lib.getBoneMass())) 82 | metrics["muscle_mass"] = float( 83 | "{:.2f}".format(lib.getMuscleMass()) 84 | ) 85 | metrics["protein"] = float( 86 | "{:.2f}".format(lib.getProteinPercentage()) 87 | ) 88 | 89 | if results.midatetime: 90 | metrics["timestamp"] = results.midatetime 91 | 92 | messages.append( 93 | MqttMessage( 94 | topic=self.format_topic("users/" + user), payload=metrics 95 | ) 96 | ) 97 | 98 | return messages 99 | 100 | def _get_data(self): 101 | from bluepy import btle 102 | 103 | scan_processor = ScanProcessor(self.mac) 104 | scanner = btle.Scanner().withDelegate(scan_processor) 105 | scanner.scan(self.SCAN_TIMEOUT, passive=True) 106 | 107 | with timeout( 108 | self.SCAN_TIMEOUT, 109 | exception=DeviceTimeoutError( 110 | "Retrieving data from {} device {} timed out after {} seconds".format( 111 | repr(self), self.mac, self.SCAN_TIMEOUT 112 | ) 113 | ), 114 | ): 115 | while not scan_processor.ready: 116 | time.sleep(1) 117 | return scan_processor.results 118 | 119 | return scan_processor.results 120 | 121 | 122 | class ScanProcessor: 123 | def __init__(self, mac): 124 | self._ready = False 125 | self._mac = mac 126 | self._results = MiWeightScaleData() 127 | 128 | def handleDiscovery(self, dev, isNewDev, _): 129 | if dev.addr == self.mac.lower() and isNewDev: 130 | for (sdid, desc, data) in dev.getScanData(): 131 | 132 | # Xiaomi Scale V1 133 | if data.startswith("1d18") and sdid == 22: 134 | measunit = data[4:6] 135 | measured = int((data[8:10] + data[6:8]), 16) * 0.01 136 | unit = "" 137 | 138 | if measunit.startswith(("03", "b3")): 139 | unit = "lbs" 140 | elif measunit.startswith(("12", "b2")): 141 | unit = "jin" 142 | elif measunit.startswith(("22", "a2")): 143 | unit = "kg" 144 | measured = measured / 2 145 | 146 | self.results.weight = round(measured, 2) 147 | self.results.unit = unit 148 | 149 | self.ready = True 150 | 151 | # Xiaomi Scale V2 152 | if data.startswith("1b18") and sdid == 22: 153 | measunit = data[4:6] 154 | measured = int((data[28:30] + data[26:28]), 16) * 0.01 155 | unit = "" 156 | 157 | if measunit == "03": 158 | unit = "lbs" 159 | elif measunit == "02": 160 | unit = "kg" 161 | measured = measured / 2 162 | 163 | midatetime = datetime.strptime( 164 | str(int((data[10:12] + data[8:10]), 16)) 165 | + " " 166 | + str(int((data[12:14]), 16)) 167 | + " " 168 | + str(int((data[14:16]), 16)) 169 | + " " 170 | + str(int((data[16:18]), 16)) 171 | + " " 172 | + str(int((data[18:20]), 16)) 173 | + " " 174 | + str(int((data[20:22]), 16)), 175 | "%Y %m %d %H %M %S", 176 | ) 177 | 178 | self.results.weight = round(measured, 2) 179 | self.results.unit = unit 180 | self.results.impedance = int((data[24:26] + data[22:24]), 16) 181 | self.results.midatetime = str(midatetime) 182 | 183 | self.ready = True 184 | 185 | @property 186 | def mac(self): 187 | return self._mac 188 | 189 | @property 190 | def ready(self): 191 | return self._ready 192 | 193 | @ready.setter 194 | def ready(self, var): 195 | self._ready = var 196 | 197 | @property 198 | def results(self): 199 | return self._results 200 | 201 | 202 | class MiWeightScaleData: 203 | def __init__(self): 204 | self._weight = None 205 | self._unit = None 206 | self._midatetime = None 207 | self._impedance = None 208 | 209 | @property 210 | def weight(self): 211 | return self._weight 212 | 213 | @weight.setter 214 | def weight(self, var): 215 | self._weight = var 216 | 217 | @property 218 | def unit(self): 219 | return self._unit 220 | 221 | @unit.setter 222 | def unit(self, var): 223 | self._unit = var 224 | 225 | @property 226 | def midatetime(self): 227 | return self._midatetime 228 | 229 | @midatetime.setter 230 | def midatetime(self, var): 231 | self._midatetime = var 232 | 233 | @property 234 | def impedance(self): 235 | return self._impedance 236 | 237 | @impedance.setter 238 | def impedance(self, var): 239 | self._impedance = var 240 | 241 | 242 | class bodyMetrics: 243 | def __init__(self, weight, unit, height, age, sex, impedance): 244 | # Calculations need weight to be in kg, check unit and convert to kg if needed 245 | if unit == "lbs": 246 | weight = weight / 2.20462 247 | 248 | self.weight = weight 249 | self.height = height 250 | self.age = age 251 | self.sex = sex 252 | self.impedance = impedance 253 | 254 | # Check for potential out of boundaries 255 | if self.height > 220: 256 | raise Exception("Height is too high (limit: >220cm)") 257 | elif weight < 10 or weight > 200: 258 | raise Exception( 259 | "Weight is either too low or too high (limits: <10kg and >200kg)" 260 | ) 261 | elif age > 99: 262 | raise Exception("Age is too high (limit >99 years)") 263 | 264 | def is_impedance_value_valid(self): 265 | # Impedance could be 0 if someone gets off the MiScale just after the weight measurement, but before the 266 | # impedance measurement. Impedance could be high value (usually 65533), for example when someone 267 | # is not barefoot. 268 | return isinstance(self.impedance, int) and 0 < self.impedance <= 3000 269 | 270 | # Set the value to a boundary if it overflows 271 | def checkValueOverflow(self, value, minimum, maximum): 272 | if value < minimum: 273 | return minimum 274 | elif value > maximum: 275 | return maximum 276 | else: 277 | return value 278 | 279 | # Get LBM coefficient (with impedance) 280 | def getLBMCoefficient(self): 281 | if not self.is_impedance_value_valid(): 282 | raise Exception("Impedance is not valid, LBM could not be calculated.") 283 | 284 | lbm = (self.height * 9.058 / 100) * (self.height / 100) 285 | lbm += self.weight * 0.32 + 12.226 286 | lbm -= self.impedance * 0.0068 287 | lbm -= self.age * 0.0542 288 | return lbm 289 | 290 | # Get BMR 291 | def getBMR(self): 292 | if self.sex == "female": 293 | bmr = 864.6 + self.weight * 10.2036 294 | bmr -= self.height * 0.39336 295 | bmr -= self.age * 6.204 296 | else: 297 | bmr = 877.8 + self.weight * 14.916 298 | bmr -= self.height * 0.726 299 | bmr -= self.age * 8.976 300 | 301 | # Capping 302 | if self.sex == "female" and bmr > 2996: 303 | bmr = 5000 304 | elif self.sex == "male" and bmr > 2322: 305 | bmr = 5000 306 | return self.checkValueOverflow(bmr, 500, 10000) 307 | 308 | # Get BMR scale 309 | def getBMRScale(self): 310 | coefficients = { 311 | "female": {12: 34, 15: 29, 17: 24, 29: 22, 50: 20, 120: 19}, 312 | "male": {12: 36, 15: 30, 17: 26, 29: 23, 50: 21, 120: 20}, 313 | } 314 | 315 | for age, coefficient in coefficients[self.sex].items(): 316 | if self.age < age: 317 | return [self.weight * coefficient] 318 | break 319 | 320 | # Get fat percentage 321 | def getFatPercentage(self): 322 | # Set a constant to remove from LBM 323 | if self.sex == "female" and self.age <= 49: 324 | const = 9.25 325 | elif self.sex == "female" and self.age > 49: 326 | const = 7.25 327 | else: 328 | const = 0.8 329 | 330 | # Calculate body fat percentage 331 | LBM = self.getLBMCoefficient() 332 | 333 | if self.sex == "male" and self.weight < 61: 334 | coefficient = 0.98 335 | elif self.sex == "female" and self.weight > 60: 336 | coefficient = 0.96 337 | if self.height > 160: 338 | coefficient *= 1.03 339 | elif self.sex == "female" and self.weight < 50: 340 | coefficient = 1.02 341 | if self.height > 160: 342 | coefficient *= 1.03 343 | else: 344 | coefficient = 1.0 345 | fatPercentage = (1.0 - (((LBM - const) * coefficient) / self.weight)) * 100 346 | 347 | # Capping body fat percentage 348 | if fatPercentage > 63: 349 | fatPercentage = 75 350 | return self.checkValueOverflow(fatPercentage, 5, 75) 351 | 352 | # Get fat percentage scale 353 | def getFatPercentageScale(self): 354 | # The included tables where quite strange, maybe bogus, replaced them with better ones... 355 | scales = [ 356 | {"min": 0, "max": 20, "female": [18, 23, 30, 35], "male": [8, 14, 21, 25]}, 357 | { 358 | "min": 21, 359 | "max": 25, 360 | "female": [19, 24, 30, 35], 361 | "male": [10, 15, 22, 26], 362 | }, 363 | { 364 | "min": 26, 365 | "max": 30, 366 | "female": [20, 25, 31, 36], 367 | "male": [11, 16, 21, 27], 368 | }, 369 | { 370 | "min": 31, 371 | "max": 35, 372 | "female": [21, 26, 33, 36], 373 | "male": [13, 17, 25, 28], 374 | }, 375 | { 376 | "min": 46, 377 | "max": 40, 378 | "female": [22, 27, 34, 37], 379 | "male": [15, 20, 26, 29], 380 | }, 381 | { 382 | "min": 41, 383 | "max": 45, 384 | "female": [23, 28, 35, 38], 385 | "male": [16, 22, 27, 30], 386 | }, 387 | { 388 | "min": 46, 389 | "max": 50, 390 | "female": [24, 30, 36, 38], 391 | "male": [17, 23, 29, 31], 392 | }, 393 | { 394 | "min": 51, 395 | "max": 55, 396 | "female": [26, 31, 36, 39], 397 | "male": [19, 25, 30, 33], 398 | }, 399 | { 400 | "min": 56, 401 | "max": 100, 402 | "female": [27, 32, 37, 40], 403 | "male": [21, 26, 31, 34], 404 | }, 405 | ] 406 | 407 | for scale in scales: 408 | if self.age >= scale["min"] and self.age <= scale["max"]: 409 | return scale[self.sex] 410 | 411 | # Get water percentage 412 | def getWaterPercentage(self): 413 | waterPercentage = (100 - self.getFatPercentage()) * 0.7 414 | 415 | if waterPercentage <= 50: 416 | coefficient = 1.02 417 | else: 418 | coefficient = 0.98 419 | 420 | # Capping water percentage 421 | if waterPercentage * coefficient >= 65: 422 | waterPercentage = 75 423 | return self.checkValueOverflow(waterPercentage * coefficient, 35, 75) 424 | 425 | # Get water percentage scale 426 | def getWaterPercentageScale(self): 427 | return [53, 67] 428 | 429 | # Get bone mass 430 | def getBoneMass(self): 431 | if self.sex == "female": 432 | base = 0.245691014 433 | else: 434 | base = 0.18016894 435 | 436 | boneMass = (base - (self.getLBMCoefficient() * 0.05158)) * -1 437 | 438 | if boneMass > 2.2: 439 | boneMass += 0.1 440 | else: 441 | boneMass -= 0.1 442 | 443 | # Capping boneMass 444 | if self.sex == "female" and boneMass > 5.1: 445 | boneMass = 8 446 | elif self.sex == "male" and boneMass > 5.2: 447 | boneMass = 8 448 | return self.checkValueOverflow(boneMass, 0.5, 8) 449 | 450 | # Get bone mass scale 451 | def getBoneMassScale(self): 452 | scales = [ 453 | { 454 | "female": {"min": 60, "optimal": 2.5}, 455 | "male": {"min": 75, "optimal": 3.2}, 456 | }, 457 | { 458 | "female": {"min": 45, "optimal": 2.2}, 459 | "male": {"min": 69, "optimal": 2.9}, 460 | }, 461 | {"female": {"min": 0, "optimal": 1.8}, "male": {"min": 0, "optimal": 2.5}}, 462 | ] 463 | 464 | for scale in scales: 465 | if self.weight >= scale[self.sex]["min"]: 466 | return [scale[self.sex]["optimal"] - 1, scale[self.sex]["optimal"] + 1] 467 | 468 | # Get muscle mass 469 | def getMuscleMass(self): 470 | muscleMass = ( 471 | self.weight 472 | - ((self.getFatPercentage() * 0.01) * self.weight) 473 | - self.getBoneMass() 474 | ) 475 | 476 | # Capping muscle mass 477 | if self.sex == "female" and muscleMass >= 84: 478 | muscleMass = 120 479 | elif self.sex == "male" and muscleMass >= 93.5: 480 | muscleMass = 120 481 | 482 | return self.checkValueOverflow(muscleMass, 10, 120) 483 | 484 | # Get muscle mass scale 485 | def getMuscleMassScale(self): 486 | scales = [ 487 | {"min": 170, "female": [36.5, 42.5], "male": [49.5, 59.4]}, 488 | {"min": 160, "female": [32.9, 37.5], "male": [44.0, 52.4]}, 489 | {"min": 0, "female": [29.1, 34.7], "male": [38.5, 46.5]}, 490 | ] 491 | 492 | for scale in scales: 493 | if self.height >= scale["min"]: 494 | return scale[self.sex] 495 | 496 | # Get Visceral Fat 497 | def getVisceralFat(self): 498 | if self.sex == "female": 499 | if self.weight > (13 - (self.height * 0.5)) * -1: 500 | subsubcalc = ( 501 | (self.height * 1.45) + (self.height * 0.1158) * self.height 502 | ) - 120 503 | subcalc = self.weight * 500 / subsubcalc 504 | vfal = (subcalc - 6) + (self.age * 0.07) 505 | else: 506 | subcalc = 0.691 + (self.height * -0.0024) + (self.height * -0.0024) 507 | vfal = ( 508 | (((self.height * 0.027) - (subcalc * self.weight)) * -1) 509 | + (self.age * 0.07) 510 | - self.age 511 | ) 512 | else: 513 | if self.height < self.weight * 1.6: 514 | subcalc = ( 515 | (self.height * 0.4) - (self.height * (self.height * 0.0826)) 516 | ) * -1 517 | vfal = ((self.weight * 305) / (subcalc + 48)) - 2.9 + (self.age * 0.15) 518 | else: 519 | subcalc = 0.765 + self.height * -0.0015 520 | vfal = ( 521 | (((self.height * 0.143) - (self.weight * subcalc)) * -1) 522 | + (self.age * 0.15) 523 | - 5.0 524 | ) 525 | 526 | return self.checkValueOverflow(vfal, 1, 50) 527 | 528 | # Get visceral fat scale 529 | def getVisceralFatScale(self): 530 | return [10, 15] 531 | 532 | # Get BMI 533 | def getBMI(self): 534 | return self.checkValueOverflow( 535 | self.weight / ((self.height / 100) * (self.height / 100)), 10, 90 536 | ) 537 | 538 | # Get BMI scale 539 | def getBMIScale(self): 540 | # Replaced library's version by mi fit scale, it seems better 541 | return [18.5, 25, 28, 32] 542 | 543 | # Get ideal weight (just doing a reverse BMI, should be something better) 544 | def getIdealWeight(self): 545 | return self.checkValueOverflow( 546 | (22 * self.height) * self.height / 10000, 5.5, 198 547 | ) 548 | 549 | # Get ideal weight scale (BMI scale converted to weights) 550 | def getIdealWeightScale(self): 551 | scale = [] 552 | for bmiScale in self.getBMIScale(): 553 | scale.append((bmiScale * self.height) * self.height / 10000) 554 | return scale 555 | 556 | # Get fat mass to ideal (guessing mi fit formula) 557 | def getFatMassToIdeal(self): 558 | mass = (self.weight * (self.getFatPercentage() / 100)) - ( 559 | self.weight * (self.getFatPercentageScale()[2] / 100) 560 | ) 561 | if mass < 0: 562 | return {"type": "to_gain", "mass": mass * -1} 563 | else: 564 | return {"type": "to_lose", "mass": mass} 565 | 566 | # Get protetin percentage (warn: guessed formula) 567 | def getProteinPercentage(self): 568 | proteinPercentage = 100 - (floor(self.getFatPercentage() * 100) / 100) 569 | proteinPercentage -= floor(self.getWaterPercentage() * 100) / 100 570 | proteinPercentage -= floor((self.getBoneMass() / self.weight * 100) * 100) / 100 571 | return proteinPercentage 572 | 573 | # Get protein scale (hardcoded in mi fit) 574 | def getProteinPercentageScale(self): 575 | return [16, 20] 576 | 577 | # Get body type (out of nine possible) 578 | def getBodyType(self): 579 | if self.getFatPercentage() > self.getFatPercentageScale()[2]: 580 | factor = 0 581 | elif self.getFatPercentage() < self.getFatPercentageScale()[1]: 582 | factor = 2 583 | else: 584 | factor = 1 585 | 586 | if self.getMuscleMass() > self.getMuscleMassScale()[1]: 587 | return 2 + (factor * 3) 588 | elif self.getMuscleMass() < self.getMuscleMassScale()[0]: 589 | return factor * 3 590 | else: 591 | return 1 + (factor * 3) 592 | 593 | # Return body type scale 594 | def getBodyTypeScale(self): 595 | return [ 596 | "obese", 597 | "overweight", 598 | "thick-set", 599 | "lack-exerscise", 600 | "balanced", 601 | "balanced-muscular", 602 | "skinny", 603 | "balanced-skinny", 604 | "skinny-muscular", 605 | ] 606 | -------------------------------------------------------------------------------- /workers/mithermometer.py: -------------------------------------------------------------------------------- 1 | from const import DEFAULT_PER_DEVICE_TIMEOUT 2 | from exceptions import DeviceTimeoutError 3 | from mqtt import MqttMessage, MqttConfigMessage 4 | from interruptingcow import timeout 5 | 6 | from workers.base import BaseWorker, retry 7 | import logger 8 | 9 | REQUIREMENTS = ["mithermometer==0.1.4", "bluepy"] 10 | monitoredAttrs = ["temperature", "humidity", "battery"] 11 | _LOGGER = logger.get(__name__) 12 | 13 | 14 | class MithermometerWorker(BaseWorker): 15 | per_device_timeout = DEFAULT_PER_DEVICE_TIMEOUT # type: int 16 | 17 | def _setup(self): 18 | from mithermometer.mithermometer_poller import MiThermometerPoller 19 | from btlewrap.bluepy import BluepyBackend 20 | 21 | _LOGGER.info("Adding %d %s devices", len(self.devices), repr(self)) 22 | for name, mac in self.devices.items(): 23 | _LOGGER.debug("Adding %s device '%s' (%s)", repr(self), name, mac) 24 | self.devices[name] = { 25 | "mac": mac, 26 | "poller": MiThermometerPoller(mac, BluepyBackend), 27 | } 28 | 29 | def config(self, availbility_topic): 30 | ret = [] 31 | for name, data in self.devices.items(): 32 | ret += self.config_device(name, data["mac"]) 33 | return ret 34 | 35 | def config_device(self, name, mac): 36 | ret = [] 37 | device = { 38 | "identifiers": [mac, self.format_discovery_id(mac, name)], 39 | "manufacturer": "Xiaomi", 40 | "model": "LYWSD(CGQ/01ZM)", 41 | "name": self.format_discovery_name(name), 42 | } 43 | 44 | for attr in monitoredAttrs: 45 | payload = { 46 | "unique_id": self.format_discovery_id(mac, name, attr), 47 | "name": self.format_discovery_name(name, attr), 48 | "state_topic": self.format_prefixed_topic(name, attr), 49 | "device_class": attr, 50 | "device": device, 51 | } 52 | 53 | if attr == "temperature": 54 | payload["unit_of_measurement"] = "°C" 55 | elif attr == "humidity": 56 | payload["unit_of_measurement"] = "%" 57 | elif attr == "battery": 58 | payload["unit_of_measurement"] = "%" 59 | 60 | ret.append( 61 | MqttConfigMessage( 62 | MqttConfigMessage.SENSOR, 63 | self.format_discovery_topic(mac, name, attr), 64 | payload=payload, 65 | ) 66 | ) 67 | 68 | return ret 69 | 70 | def status_update(self): 71 | _LOGGER.info("Updating %d %s devices", len(self.devices), repr(self)) 72 | 73 | for name, data in self.devices.items(): 74 | _LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, data["mac"]) 75 | from btlewrap import BluetoothBackendException 76 | 77 | try: 78 | with timeout(self.per_device_timeout, exception=DeviceTimeoutError): 79 | yield retry(self.update_device_state, retries=self.update_retries, exception_type=BluetoothBackendException)(name, data["poller"]) 80 | except BluetoothBackendException as e: 81 | logger.log_exception( 82 | _LOGGER, 83 | "Error during update of %s device '%s' (%s): %s", 84 | repr(self), 85 | name, 86 | data["mac"], 87 | type(e).__name__, 88 | suppress=True, 89 | ) 90 | except DeviceTimeoutError: 91 | logger.log_exception( 92 | _LOGGER, 93 | "Time out during update of %s device '%s' (%s)", 94 | repr(self), 95 | name, 96 | data["mac"], 97 | suppress=True, 98 | ) 99 | 100 | def update_device_state(self, name, poller): 101 | ret = [] 102 | poller.clear_cache() 103 | for attr in monitoredAttrs: 104 | ret.append( 105 | MqttMessage( 106 | topic=self.format_topic(name, attr), 107 | payload=poller.parameter_value(attr), 108 | ) 109 | ) 110 | return ret 111 | -------------------------------------------------------------------------------- /workers/mysensors.py: -------------------------------------------------------------------------------- 1 | from mqtt import MqttMessage 2 | 3 | from workers.base import BaseWorker 4 | import logger 5 | 6 | REQUIREMENTS = ["pyserial"] 7 | _LOGGER = logger.get(__name__) 8 | 9 | 10 | class MysensorsWorker(BaseWorker): 11 | def run(self, mqtt): 12 | import serial 13 | 14 | with serial.Serial(self.port, self.baudrate, timeout=10) as ser: 15 | _LOGGER.debug("Starting mysensors at: %s" % ser.name) 16 | while True: 17 | line = ser.readline() 18 | if not line: 19 | continue 20 | splited_line = self.format_topic(line.decode("utf-8").strip()).split( 21 | ";" 22 | ) 23 | topic = "/".join(splited_line[0:-1]) 24 | payload = "".join(splited_line[-1]) 25 | mqtt.publish([MqttMessage(topic=topic, payload=payload)]) 26 | -------------------------------------------------------------------------------- /workers/ruuvitag.py: -------------------------------------------------------------------------------- 1 | from mqtt import MqttMessage, MqttConfigMessage 2 | from workers.base import BaseWorker 3 | 4 | import logger 5 | 6 | 7 | REQUIREMENTS = ["ruuvitag_sensor"] 8 | 9 | # Supports all attributes of Data Format 2, 3, 4 and 5 of the RuuviTag. 10 | # See https://github.com/ruuvi/ruuvi-sensor-protocols for the sensor protocols. 11 | # Available attributes: 12 | # +-----------------------------+---+---+---+---+ 13 | # | Attribute / Data Format | 2 | 3 | 4 | 5 | 14 | # +-----------------------------+---+---+---+---+ 15 | # | acceleration | | X | | X | 16 | # | acceleration_x | | X | | X | 17 | # | acceleration_y | | X | | X | 18 | # | acceleration_z | | X | | X | 19 | # | battery | | X | | X | 20 | # | data_format | X | X | X | X | 21 | # | humidity | X | X | X | X | 22 | # | identifier | | | X | | 23 | # | low_battery | | X | | X | 24 | # | mac | | | | X | 25 | # | measurement_sequence_number | | | | X | 26 | # | movement_counter | | | | X | 27 | # | pressure | X | X | X | X | 28 | # | temperature | X | X | X | X | 29 | # | tx_power | | | | X | 30 | # +-----------------------------+---+---+---+---+ 31 | ATTR_CONFIG = [ 32 | # (attribute_name, device_class, unit_of_measurement) 33 | ("acceleration", "none", "mG"), 34 | ("acceleration_x", "none", "mG"), 35 | ("acceleration_y", "none", "mG"), 36 | ("acceleration_z", "none", "mG"), 37 | ("battery", "battery", "mV"), 38 | ("data_format", "none", ""), 39 | ("humidity", "humidity", "%"), 40 | ("identifier", "none", ""), 41 | ("mac", "none", ""), 42 | ("measurement_sequence_number", "none", ""), 43 | ("movement_counter", "none", ""), 44 | ("pressure", "pressure", "hPa"), 45 | ("temperature", "temperature", "°C"), 46 | ("tx_power", "none", "dBm"), 47 | ] 48 | ATTR_LOW_BATTERY = "low_battery" 49 | # "[Y]ou should plan to replace the battery when the voltage drops below 2.5 volts" 50 | # Source: https://github.com/ruuvi/ruuvitag_fw/wiki/FAQ:-battery 51 | LOW_BATTERY_VOLTAGE = 2500 52 | _LOGGER = logger.get(__name__) 53 | 54 | 55 | class RuuvitagWorker(BaseWorker): 56 | def _setup(self): 57 | from ruuvitag_sensor.ruuvitag import RuuviTag 58 | 59 | _LOGGER.info("Adding %d %s devices", len(self.devices), repr(self)) 60 | for name, mac in self.devices.items(): 61 | _LOGGER.debug("Adding %s device '%s' (%s)", repr(self), name, mac) 62 | self.devices[name] = RuuviTag(mac) 63 | 64 | def config(self, availability_topic): 65 | ret = [] 66 | for name, device in self.devices.items(): 67 | ret.extend(self.config_device(name, device.mac)) 68 | return ret 69 | 70 | def config_device(self, name, mac): 71 | ret = [] 72 | device = { 73 | "identifiers": self.format_discovery_id(mac, name), 74 | "manufacturer": "Ruuvi", 75 | "model": "RuuviTag", 76 | "name": self.format_discovery_name(name), 77 | } 78 | 79 | for _, device_class, unit in ATTR_CONFIG: 80 | payload = { 81 | "unique_id": self.format_discovery_id(mac, name, device_class), 82 | "name": self.format_discovery_name(name, device_class), 83 | "state_topic": self.format_prefixed_topic(name, device_class), 84 | "device": device, 85 | "device_class": device_class, 86 | "unit_of_measurement": unit, 87 | } 88 | ret.append( 89 | MqttConfigMessage( 90 | MqttConfigMessage.SENSOR, 91 | self.format_discovery_topic(mac, name, device_class), 92 | payload=payload, 93 | ) 94 | ) 95 | 96 | # Add low battery config 97 | ret.append( 98 | MqttConfigMessage( 99 | MqttConfigMessage.BINARY_SENSOR, 100 | self.format_discovery_topic(mac, name, ATTR_LOW_BATTERY), 101 | payload={ 102 | "unique_id": self.format_discovery_id(mac, name, ATTR_LOW_BATTERY), 103 | "name": self.format_discovery_name(name, ATTR_LOW_BATTERY), 104 | "state_topic": self.format_prefixed_topic(name, ATTR_LOW_BATTERY), 105 | "device": device, 106 | "device_class": "battery", 107 | }, 108 | ) 109 | ) 110 | 111 | return ret 112 | 113 | def status_update(self): 114 | from bluepy import btle 115 | 116 | ret = [] 117 | _LOGGER.info("Updating %d %s devices", len(self.devices), repr(self)) 118 | for name, device in self.devices.items(): 119 | _LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, device.mac) 120 | try: 121 | ret.extend(self.update_device_state(name, device)) 122 | except btle.BTLEException as e: 123 | logger.log_exception( 124 | _LOGGER, 125 | "Error during update of %s device '%s' (%s): %s", 126 | repr(self), 127 | name, 128 | device.mac, 129 | type(e).__name__, 130 | suppress=True, 131 | ) 132 | return ret 133 | 134 | def update_device_state(self, name, device): 135 | values = device.update() 136 | 137 | ret = [] 138 | for attr, device_class, _ in ATTR_CONFIG: 139 | try: 140 | ret.append( 141 | MqttMessage( 142 | topic=self.format_topic(name, device_class), 143 | payload=values[attr], 144 | ) 145 | ) 146 | except KeyError: 147 | # The data format of this sensor doesn't have this attribute, so ignore it. 148 | pass 149 | 150 | # Low battery binary sensor 151 | # 152 | try: 153 | ret.append( 154 | MqttMessage( 155 | topic=self.format_topic(name, ATTR_LOW_BATTERY), 156 | payload=self.true_false_to_ha_on_off( 157 | values["battery"] < LOW_BATTERY_VOLTAGE 158 | ), 159 | ) 160 | ) 161 | except KeyError: 162 | # The data format of this sensor doesn't have the battery attribute, so ignore it. 163 | pass 164 | 165 | return ret 166 | -------------------------------------------------------------------------------- /workers/smartgadget.py: -------------------------------------------------------------------------------- 1 | from mqtt import MqttMessage, MqttConfigMessage 2 | from workers.base import BaseWorker 3 | 4 | import logger 5 | 6 | 7 | REQUIREMENTS = ["python-smartgadget"] 8 | ATTR_CONFIG = [ 9 | # (attribute_name, device_class, unit_of_measurement) 10 | ("temperature", "temperature", "°C"), 11 | ("humidity", "humidity", "%"), 12 | ("battery_level", "battery", "%"), 13 | ] 14 | _LOGGER = logger.get(__name__) 15 | 16 | 17 | class SmartgadgetWorker(BaseWorker): 18 | def _setup(self): 19 | from sensirionbt import SmartGadget 20 | 21 | _LOGGER.info("Adding %d %s devices", len(self.devices), repr(self)) 22 | for name, mac in self.devices.items(): 23 | _LOGGER.debug("Adding %s device '%s' (%s)", repr(self), name, mac) 24 | self.devices[name] = SmartGadget(mac) 25 | 26 | def config(self, availability_topic): 27 | ret = [] 28 | for name, device in self.devices.items(): 29 | ret.extend(self.config_device(name, device.mac)) 30 | return ret 31 | 32 | def config_device(self, name, mac): 33 | ret = [] 34 | device = { 35 | "identifiers": self.format_discovery_id(mac, name), 36 | "manufacturer": "Sensirion AG", 37 | "model": "SmartGadget", 38 | "name": self.format_discovery_name(name), 39 | } 40 | 41 | for attr, device_class, unit in ATTR_CONFIG: 42 | payload = { 43 | "unique_id": self.format_discovery_id(mac, name, device_class), 44 | "name": self.format_discovery_name(name, device_class), 45 | "state_topic": self.format_prefixed_topic(name, device_class), 46 | "device": device, 47 | "device_class": device_class, 48 | "unit_of_measurement": unit, 49 | } 50 | ret.append( 51 | MqttConfigMessage( 52 | MqttConfigMessage.SENSOR, 53 | self.format_discovery_topic(mac, name, device_class), 54 | payload=payload, 55 | ) 56 | ) 57 | 58 | return ret 59 | 60 | def status_update(self): 61 | from bluepy import btle 62 | 63 | _LOGGER.info("Updating %d %s devices", len(self.devices), repr(self)) 64 | for name, device in self.devices.items(): 65 | _LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, device.mac) 66 | try: 67 | yield self.update_device_state(name, device) 68 | except btle.BTLEException as e: 69 | logger.log_exception( 70 | _LOGGER, 71 | "Error during update of %s device '%s' (%s): %s", 72 | repr(self), 73 | name, 74 | device.mac, 75 | type(e).__name__, 76 | suppress=True, 77 | ) 78 | 79 | def update_device_state(self, name, device): 80 | values = device.get_values() 81 | 82 | ret = [] 83 | for attr, device_class, _ in ATTR_CONFIG: 84 | ret.append( 85 | MqttMessage( 86 | topic=self.format_topic(name, device_class), payload=values[attr] 87 | ) 88 | ) 89 | 90 | return ret 91 | -------------------------------------------------------------------------------- /workers/switchbot.py: -------------------------------------------------------------------------------- 1 | from mqtt import MqttMessage 2 | 3 | from workers.base import BaseWorker, retry 4 | import logger 5 | 6 | REQUIREMENTS = ["bluepy"] 7 | _LOGGER = logger.get(__name__) 8 | 9 | STATE_ON = "ON" 10 | STATE_OFF = "OFF" 11 | CODES = { 12 | STATE_ON: "570101", 13 | STATE_OFF: "570102", 14 | "PRESS": "570100" 15 | } 16 | 17 | SERVICE_UUID = "cba20d00-224d-11e6-9fb8-0002a5d5c51b" 18 | CHARACTERISTIC_UUID = "cba20002-224d-11e6-9fb8-0002a5d5c51b" 19 | 20 | 21 | class SwitchbotWorker(BaseWorker): 22 | def _setup(self): 23 | 24 | _LOGGER.info("Adding %d %s devices", len(self.devices), repr(self)) 25 | for name, mac in self.devices.items(): 26 | _LOGGER.info("Adding %s device '%s' (%s)", repr(self), name, mac) 27 | self.devices[name] = {"bot": None, "state": STATE_OFF, "mac": mac} 28 | 29 | def format_state_topic(self, *args): 30 | return "/".join([self.state_topic_prefix, *args]) 31 | 32 | def status_update(self): 33 | 34 | ret = [] 35 | _LOGGER.debug("Updating %d %s devices", len(self.devices), repr(self)) 36 | for name, bot in self.devices.items(): 37 | _LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, bot["mac"]) 38 | ret += self.update_device_state(name, bot["state"]) 39 | return ret 40 | 41 | def on_command(self, topic, value): 42 | from bluepy.btle import BTLEException 43 | 44 | _, _, device_name, _ = topic.split("/") 45 | 46 | bot = self.devices[device_name] 47 | 48 | switch_func = retry(switch_state, retries=self.command_retries) 49 | 50 | value = value.decode("utf-8") 51 | 52 | _LOGGER.info( 53 | "Setting %s on %s device '%s' (%s)", 54 | value, 55 | repr(self), 56 | device_name, 57 | bot["mac"], 58 | ) 59 | 60 | # If status doesn't change, the switchbot shouldn't move 61 | if bot['state'] == value: 62 | _LOGGER.debug( 63 | "Ignoring %s on %s device '%s' with state %s", 64 | value, 65 | repr(self), 66 | device_name, 67 | bot["state"], 68 | ) 69 | return [] 70 | 71 | try: 72 | switch_func(bot, value) 73 | except BTLEException as e: 74 | logger.log_exception( 75 | _LOGGER, 76 | "Error setting %s on %s device '%s' (%s): %s", 77 | value, 78 | repr(self), 79 | device_name, 80 | bot["mac"], 81 | type(e).__name__, 82 | ) 83 | return [] 84 | 85 | return self.update_device_state(device_name, value) 86 | 87 | def update_device_state(self, name, value): 88 | return [MqttMessage(topic=self.format_state_topic(name), payload=value)] 89 | 90 | 91 | def switch_state(bot, value): 92 | import binascii 93 | from bluepy.btle import Peripheral 94 | 95 | bot["bot"] = Peripheral(bot["mac"], "random") 96 | hand_service = bot["bot"].getServiceByUUID(SERVICE_UUID) 97 | hand = hand_service.getCharacteristics(CHARACTERISTIC_UUID)[0] 98 | hand.write(binascii.a2b_hex(CODES[value])) 99 | bot["bot"].disconnect() 100 | bot['state'] = STATE_ON if bot['state'] == STATE_OFF else STATE_OFF 101 | -------------------------------------------------------------------------------- /workers/thermostat.py: -------------------------------------------------------------------------------- 1 | from mqtt import MqttMessage, MqttConfigMessage 2 | 3 | from workers.base import BaseWorker, retry 4 | import logger 5 | 6 | REQUIREMENTS = ["python-eq3bt==0.1.12"] 7 | _LOGGER = logger.get(__name__) 8 | 9 | MODE_HEAT = "heat" 10 | MODE_AUTO = "auto" 11 | MODE_OFF = "off" 12 | 13 | PRESET_NONE = "none" 14 | PRESET_BOOST = "boost" 15 | PRESET_COMFORT = "comfort" 16 | PRESET_ECO = "eco" 17 | PRESET_AWAY = "away" 18 | 19 | SENSOR_CLIMATE = "climate" 20 | SENSOR_WINDOW = "window_open" 21 | SENSOR_BATTERY = "low_battery" 22 | SENSOR_LOCKED = "locked" 23 | SENSOR_VALVE = "valve_state" 24 | SENSOR_AWAY_END = "away_end" 25 | SENSOR_TARGET_TEMPERATURE = "target_temperature" 26 | 27 | monitoredAttrs = [ 28 | SENSOR_BATTERY, 29 | SENSOR_VALVE, 30 | SENSOR_TARGET_TEMPERATURE, 31 | SENSOR_WINDOW, 32 | SENSOR_LOCKED, 33 | ] 34 | 35 | 36 | class ThermostatWorker(BaseWorker): 37 | def _setup(self): 38 | from eq3bt import Thermostat 39 | 40 | _LOGGER.info("Adding %d %s devices", len(self.devices), repr(self)) 41 | for name, obj in self.devices.items(): 42 | if isinstance(obj, str): 43 | self.devices[name] = {"mac": obj, "thermostat": Thermostat(obj)} 44 | elif isinstance(obj, dict): 45 | self.devices[name] = { 46 | "mac": obj["mac"], 47 | "thermostat": Thermostat(obj["mac"], obj.get("interface")), 48 | "discovery_temperature_topic": obj.get( 49 | "discovery_temperature_topic" 50 | ), 51 | "discovery_temperature_template": obj.get( 52 | "discovery_temperature_template" 53 | ), 54 | } 55 | else: 56 | raise TypeError("Unsupported configuration format") 57 | _LOGGER.debug( 58 | "Adding %s device '%s' (%s)", 59 | repr(self), 60 | name, 61 | self.devices[name]["mac"], 62 | ) 63 | 64 | def config(self, availability_topic): 65 | ret = [] 66 | for name, data in self.devices.items(): 67 | ret += self.config_device(name, data, availability_topic) 68 | return ret 69 | 70 | def config_device(self, name, data, availability_topic): 71 | ret = [] 72 | mac = data["mac"] 73 | device = { 74 | "identifiers": [mac, self.format_discovery_id(mac, name)], 75 | "manufacturer": "eQ-3", 76 | "model": "Smart Radiator Thermostat", 77 | "name": self.format_discovery_name(name), 78 | } 79 | 80 | payload = { 81 | "unique_id": self.format_discovery_id(mac, name, SENSOR_CLIMATE), 82 | "name": self.format_discovery_name(name, SENSOR_CLIMATE), 83 | "qos": 1, 84 | "availability_topic": availability_topic, 85 | "temperature_state_topic": self.format_prefixed_topic( 86 | name, SENSOR_TARGET_TEMPERATURE 87 | ), 88 | "temperature_command_topic": self.format_prefixed_topic( 89 | name, SENSOR_TARGET_TEMPERATURE, "set" 90 | ), 91 | "mode_state_topic": self.format_prefixed_topic(name, "mode"), 92 | "mode_command_topic": self.format_prefixed_topic(name, "mode", "set"), 93 | "preset_mode_state_topic": self.format_prefixed_topic(name, "preset"), 94 | "preset_mode_command_topic": self.format_prefixed_topic(name, "preset", "set"), 95 | "json_attributes_topic": self.format_prefixed_topic( 96 | name, "json_attributes" 97 | ), 98 | "min_temp": 5.0, 99 | "max_temp": 29.5, 100 | "temp_step": 0.5, 101 | "modes": [MODE_HEAT, MODE_AUTO, MODE_OFF], 102 | "preset_modes": [PRESET_BOOST, PRESET_COMFORT, PRESET_ECO, PRESET_AWAY], 103 | "device": device, 104 | } 105 | if data.get("discovery_temperature_topic"): 106 | payload["current_temperature_topic"] = data["discovery_temperature_topic"] 107 | if data.get("discovery_temperature_template"): 108 | payload["current_temperature_template"] = data[ 109 | "discovery_temperature_template" 110 | ] 111 | ret.append( 112 | MqttConfigMessage( 113 | MqttConfigMessage.CLIMATE, 114 | self.format_discovery_topic(mac, name, SENSOR_CLIMATE), 115 | payload=payload, 116 | ) 117 | ) 118 | 119 | payload = { 120 | "unique_id": self.format_discovery_id(mac, name, SENSOR_WINDOW), 121 | "name": self.format_discovery_name(name, SENSOR_WINDOW), 122 | "state_topic": self.format_prefixed_topic(name, SENSOR_WINDOW), 123 | "availability_topic": availability_topic, 124 | "device_class": "window", 125 | "payload_on": "true", 126 | "payload_off": "false", 127 | "device": device, 128 | } 129 | ret.append( 130 | MqttConfigMessage( 131 | MqttConfigMessage.BINARY_SENSOR, 132 | self.format_discovery_topic(mac, name, SENSOR_WINDOW), 133 | payload=payload, 134 | ) 135 | ) 136 | 137 | payload = { 138 | "unique_id": self.format_discovery_id(mac, name, SENSOR_BATTERY), 139 | "name": self.format_discovery_name(name, SENSOR_BATTERY), 140 | "state_topic": self.format_prefixed_topic(name, SENSOR_BATTERY), 141 | "availability_topic": availability_topic, 142 | "device_class": "battery", 143 | "payload_on": "true", 144 | "payload_off": "false", 145 | "device": device, 146 | } 147 | ret.append( 148 | MqttConfigMessage( 149 | MqttConfigMessage.BINARY_SENSOR, 150 | self.format_discovery_topic(mac, name, SENSOR_BATTERY), 151 | payload=payload, 152 | ) 153 | ) 154 | 155 | payload = { 156 | "unique_id": self.format_discovery_id(mac, name, SENSOR_LOCKED), 157 | "name": self.format_discovery_name(name, SENSOR_LOCKED), 158 | "state_topic": self.format_prefixed_topic(name, SENSOR_LOCKED), 159 | "availability_topic": availability_topic, 160 | "device_class": "lock", 161 | "payload_on": "false", 162 | "payload_off": "true", 163 | "device": device, 164 | } 165 | ret.append( 166 | MqttConfigMessage( 167 | MqttConfigMessage.BINARY_SENSOR, 168 | self.format_discovery_topic(mac, name, SENSOR_LOCKED), 169 | payload=payload, 170 | ) 171 | ) 172 | 173 | payload = { 174 | "unique_id": self.format_discovery_id(mac, name, SENSOR_VALVE), 175 | "name": self.format_discovery_name(name, SENSOR_VALVE), 176 | "state_topic": self.format_prefixed_topic(name, SENSOR_VALVE), 177 | "availability_topic": availability_topic, 178 | "device_class": "power_factor", 179 | "unit_of_measurement": "%", 180 | "state_class": "measurement", 181 | "device": device, 182 | } 183 | ret.append( 184 | MqttConfigMessage( 185 | MqttConfigMessage.SENSOR, 186 | self.format_discovery_topic(mac, name, SENSOR_VALVE), 187 | payload=payload, 188 | ) 189 | ) 190 | 191 | return ret 192 | 193 | def status_update(self): 194 | from bluepy import btle 195 | 196 | _LOGGER.info("Updating %d %s devices", len(self.devices), repr(self)) 197 | for name, data in self.devices.items(): 198 | _LOGGER.debug("Updating %s device '%s' (%s)", repr(self), name, data["mac"]) 199 | thermostat = data["thermostat"] 200 | try: 201 | retry(thermostat.update, retries=self.update_retries, exception_type=btle.BTLEException)() 202 | except btle.BTLEException as e: 203 | logger.log_exception( 204 | _LOGGER, 205 | "Error during update of %s device '%s' (%s): %s", 206 | repr(self), 207 | name, 208 | data["mac"], 209 | type(e).__name__, 210 | suppress=True, 211 | ) 212 | else: 213 | yield retry(self.present_device_state, retries=self.update_retries, exception_type=btle.BTLEException)(name, thermostat) 214 | 215 | def on_command(self, topic, value): 216 | from bluepy import btle 217 | from eq3bt import Mode 218 | 219 | default_fallback_mode = Mode.Auto 220 | 221 | topic_without_prefix = topic.replace("{}/".format(self.topic_prefix), "") 222 | device_name, method, _ = topic_without_prefix.split("/") 223 | 224 | if device_name in self.devices: 225 | data = self.devices[device_name] 226 | thermostat = data["thermostat"] 227 | else: 228 | logger.log_exception(_LOGGER, "Ignore command because device %s is unknown", device_name) 229 | return [] 230 | 231 | value = value.decode("utf-8") 232 | if method == "mode": 233 | state_mapping = { 234 | MODE_HEAT: Mode.Manual, 235 | MODE_AUTO: Mode.Auto, 236 | MODE_OFF: Mode.Closed, 237 | } 238 | if value in state_mapping: 239 | value = state_mapping[value] 240 | else: 241 | logger.log_exception(_LOGGER, "Invalid mode setting %s", value) 242 | return [] 243 | 244 | elif method == "preset": 245 | if value == PRESET_BOOST: 246 | method = "mode" 247 | value = Mode.Boost 248 | elif value in (PRESET_COMFORT, PRESET_ECO): 249 | method = "preset" 250 | elif value == PRESET_AWAY: 251 | method = "mode" 252 | value = Mode.Away 253 | elif value == PRESET_NONE: 254 | method = "mode" 255 | value = default_fallback_mode 256 | else: 257 | logger.log_exception(_LOGGER, "Invalid preset setting %s", value) 258 | return [] 259 | 260 | elif method == "target_temperature": 261 | value = float(value) 262 | 263 | _LOGGER.info( 264 | "Setting %s to %s on %s device '%s' (%s)", 265 | method, 266 | value, 267 | repr(self), 268 | device_name, 269 | data["mac"], 270 | ) 271 | try: 272 | if method == "preset": 273 | if value == PRESET_COMFORT: 274 | retry(thermostat.activate_comfort, retries=self.command_retries, exception_type=btle.BTLEException)() 275 | else: 276 | retry(thermostat.activate_eco, retries=self.command_retries, exception_type=btle.BTLEException)() 277 | else: 278 | retry(setattr, retries=self.command_retries, exception_type=btle.BTLEException)(thermostat, method, value) 279 | except btle.BTLEException as e: 280 | logger.log_exception( 281 | _LOGGER, 282 | "Error setting %s to %s on %s device '%s' (%s): %s", 283 | method, 284 | value, 285 | repr(self), 286 | device_name, 287 | data["mac"], 288 | type(e).__name__, 289 | ) 290 | return [] 291 | 292 | return retry(self.present_device_state, retries=self.command_retries, exception_type=btle.BTLEException)(device_name, thermostat) 293 | 294 | def present_device_state(self, name, thermostat): 295 | from eq3bt import Mode 296 | 297 | ret = [] 298 | attributes = {} 299 | for attr in monitoredAttrs: 300 | value = getattr(thermostat, attr) 301 | ret.append(MqttMessage(topic=self.format_topic(name, attr), payload=value)) 302 | 303 | if attr != SENSOR_TARGET_TEMPERATURE: 304 | attributes[attr] = value 305 | 306 | if thermostat.away_end: 307 | attributes[SENSOR_AWAY_END] = thermostat.away_end.isoformat() 308 | else: 309 | attributes[SENSOR_AWAY_END] = None 310 | 311 | ret.append( 312 | MqttMessage( 313 | topic=self.format_topic(name, "json_attributes"), payload=attributes 314 | ) 315 | ) 316 | 317 | mapping = { 318 | Mode.Auto: MODE_AUTO, 319 | Mode.Closed: MODE_OFF, 320 | Mode.Boost: MODE_AUTO, 321 | } 322 | mode = mapping.get(thermostat.mode, MODE_HEAT) 323 | 324 | if thermostat.mode == Mode.Boost: 325 | preset = PRESET_BOOST 326 | elif thermostat.mode == Mode.Away: 327 | preset = PRESET_AWAY 328 | elif thermostat.target_temperature == thermostat.comfort_temperature: 329 | preset = PRESET_COMFORT 330 | elif thermostat.target_temperature == thermostat.eco_temperature: 331 | preset = PRESET_ECO 332 | else: 333 | preset = PRESET_NONE 334 | 335 | ret.append(MqttMessage(topic=self.format_topic(name, "mode"), payload=mode)) 336 | ret.append(MqttMessage(topic=self.format_topic(name, "preset"), payload=preset)) 337 | 338 | return ret 339 | -------------------------------------------------------------------------------- /workers/toothbrush.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from mqtt import MqttMessage 4 | 5 | from workers.base import BaseWorker 6 | import logger 7 | 8 | REQUIREMENTS = ["bluepy"] 9 | _LOGGER = logger.get(__name__) 10 | 11 | 12 | class ToothbrushWorker(BaseWorker): 13 | def searchmac(self, devices, mac): 14 | for dev in devices: 15 | if dev.addr == mac.lower(): 16 | return dev 17 | 18 | return None 19 | 20 | def status_update(self): 21 | from bluepy.btle import Scanner, DefaultDelegate 22 | 23 | class ScanDelegate(DefaultDelegate): 24 | def __init__(self): 25 | DefaultDelegate.__init__(self) 26 | 27 | def handleDiscovery(self, dev, isNewDev, isNewData): 28 | if isNewDev: 29 | _LOGGER.debug("Discovered new device: %s" % dev.addr) 30 | 31 | scanner = Scanner().withDelegate(ScanDelegate()) 32 | devices = scanner.scan(5.0) 33 | ret = [] 34 | 35 | for name, mac in self.devices.items(): 36 | device = self.searchmac(devices, mac) 37 | if device is None: 38 | ret.append( 39 | MqttMessage( 40 | topic=self.format_topic(name + "/presence"), payload="0" 41 | ) 42 | ) 43 | else: 44 | ret.append( 45 | MqttMessage( 46 | topic=self.format_topic(name + "/presence/rssi"), 47 | payload=device.rssi, 48 | ) 49 | ) 50 | ret.append( 51 | MqttMessage( 52 | topic=self.format_topic(name + "/presence"), payload="1" 53 | ) 54 | ) 55 | _LOGGER.debug("text: %s" % device.getValueText(255)) 56 | bytes_ = bytearray(bytes.fromhex(device.getValueText(255))) 57 | ret.append( 58 | MqttMessage( 59 | topic=self.format_topic(name + "/running"), payload=bytes_[5] 60 | ) 61 | ) 62 | ret.append( 63 | MqttMessage( 64 | topic=self.format_topic(name + "/pressure"), payload=bytes_[6] 65 | ) 66 | ) 67 | ret.append( 68 | MqttMessage( 69 | topic=self.format_topic(name + "/time"), 70 | payload=bytes_[7] * 60 + bytes_[8], 71 | ) 72 | ) 73 | ret.append( 74 | MqttMessage( 75 | topic=self.format_topic(name + "/mode"), payload=bytes_[9] 76 | ) 77 | ) 78 | ret.append( 79 | MqttMessage( 80 | topic=self.format_topic(name + "/quadrant"), payload=bytes_[10] 81 | ) 82 | ) 83 | 84 | yield ret 85 | -------------------------------------------------------------------------------- /workers/toothbrush_homeassistant.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from mqtt import MqttMessage 4 | 5 | from workers.base import BaseWorker 6 | import logger 7 | 8 | REQUIREMENTS = ["bluepy"] 9 | _LOGGER = logger.get(__name__) 10 | 11 | BRUSHSTATES = { 12 | 0: "Unknown", 13 | 1: "Initializing", 14 | 2: "Idle", 15 | 3: "Running", 16 | 4: "Charging", 17 | 5: "Setup", 18 | 6: "Flight Menu", 19 | 113: "Final Test", 20 | 114: "PCB Test", 21 | 115: "Sleeping", 22 | 116: "Transport", 23 | } 24 | 25 | BRUSHMODES = { 26 | 0: "Off", 27 | 1: "Daily Clean", 28 | 2: "Sensitive", 29 | 3: "Massage", 30 | 4: "Whitening", 31 | 5: "Deep Clean", 32 | 6: "Tongue Cleaning", 33 | 7: "Turbo", 34 | 255: "Unknown", 35 | } 36 | 37 | BRUSHSECTORS = { 38 | 0: "Sector 1", 39 | 1: "Sector 2", 40 | 2: "Sector 3", 41 | 3: "Sector 4", 42 | 4: "Sector 5", 43 | 5: "Sector 6", 44 | 7: "Sector 7", 45 | 8: "Sector 8", 46 | 254: "Last sector", 47 | 255: "No sector", 48 | } 49 | 50 | 51 | class Toothbrush_HomeassistantWorker(BaseWorker): 52 | def _setup(self): 53 | self.autoconfCache = {} 54 | 55 | def searchmac(self, devices, mac): 56 | for dev in devices: 57 | if dev.addr == mac.lower(): 58 | return dev 59 | return None 60 | 61 | def get_autoconf_data(self, key, name): 62 | if key in self.autoconfCache: 63 | return False 64 | else: 65 | self.autoconfCache[key] = True 66 | return { 67 | "platform": "mqtt", 68 | "name": name, 69 | "state_topic": self.topic_prefix + "/" + key + "/state", 70 | "availability_topic": self.topic_prefix + "/" + key + "/presence", 71 | "json_attributes_topic": self.topic_prefix + "/" + key + "/attributes", 72 | "icon": "mdi:tooth-outline", 73 | } 74 | 75 | def get_state(self, item): 76 | if item in BRUSHSTATES: 77 | return BRUSHSTATES[item] 78 | else: 79 | return BRUSHSTATES[0] 80 | 81 | def get_mode(self, item): 82 | if item in BRUSHMODES: 83 | return BRUSHMODES[item] 84 | else: 85 | return BRUSHMODES[255] 86 | 87 | def get_sector(self, item): 88 | if item in BRUSHSECTORS: 89 | return BRUSHSECTORS[item] 90 | else: 91 | return BRUSHSECTORS[255] 92 | 93 | def status_update(self): 94 | from bluepy.btle import Scanner, DefaultDelegate 95 | 96 | class ScanDelegate(DefaultDelegate): 97 | def __init__(self): 98 | DefaultDelegate.__init__(self) 99 | 100 | def handleDiscovery(self, dev, isNewDev, isNewData): 101 | if isNewDev: 102 | _LOGGER.debug("Discovered new device: %s" % dev.addr) 103 | 104 | scanner = Scanner().withDelegate(ScanDelegate()) 105 | devices = scanner.scan(5.0) 106 | ret = [] 107 | 108 | for key, item in self.devices.items(): 109 | device = self.searchmac(devices, item["mac"]) 110 | 111 | rssi = 0 112 | presence = 0 113 | state = 0 114 | pressure = 0 115 | time = 0 116 | mode = 255 117 | sector = 255 118 | 119 | if device is not None: 120 | bytes_ = bytearray.fromhex(device.getValueText(255)) 121 | _LOGGER.debug("text: %s" % device.getValueText(255)) 122 | 123 | if bytes_[5] > 0: 124 | rssi = device.rssi 125 | presence = 1 126 | state = bytes_[5] 127 | pressure = bytes_[6] 128 | time = bytes_[7] * 60 + bytes_[8] 129 | mode = bytes_[9] 130 | sector = bytes_[10] 131 | 132 | attributes = { 133 | "rssi": rssi, 134 | "pressure": pressure, 135 | "time": time, 136 | "mode": self.get_mode(mode), 137 | "sector": self.get_sector(sector), 138 | } 139 | presence_value = "online" if presence == 1 else "offline" 140 | 141 | ret.append( 142 | MqttMessage( 143 | topic=self.format_topic(key + "/presence"), payload=presence_value 144 | ) 145 | ) 146 | ret.append( 147 | MqttMessage( 148 | topic=self.format_topic(key + "/state"), 149 | payload=self.get_state(state), 150 | ) 151 | ) 152 | ret.append( 153 | MqttMessage( 154 | topic=self.format_topic(key + "/attributes"), 155 | payload=json.dumps(attributes), 156 | ) 157 | ) 158 | 159 | autoconf_data = self.get_autoconf_data(key, item["name"]) 160 | if autoconf_data != False: 161 | ret.append( 162 | MqttMessage( 163 | topic=self.autodiscovery_prefix 164 | + "/sensor/" 165 | + self.topic_prefix 166 | + "_" 167 | + key 168 | + "/config", 169 | payload=json.dumps(autoconf_data), 170 | retain=True, 171 | ) 172 | ) 173 | 174 | yield ret 175 | -------------------------------------------------------------------------------- /workers_manager.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | import threading 4 | from functools import partial 5 | 6 | from apscheduler.schedulers.background import BackgroundScheduler 7 | from interruptingcow import timeout 8 | from pytz import utc 9 | 10 | from const import DEFAULT_COMMAND_TIMEOUT, DEFAULT_COMMAND_RETRIES, DEFAULT_UPDATE_RETRIES 11 | from exceptions import WorkerTimeoutError 12 | from workers_queue import _WORKERS_QUEUE 13 | import logger 14 | 15 | _LOGGER = logger.get(__name__) 16 | 17 | 18 | class WorkersManager: 19 | class Command: 20 | def __init__(self, callback, timeout, args=(), options=dict()): 21 | self._callback = callback 22 | self._timeout = timeout 23 | self._args = args 24 | self._options = options 25 | self._source = "{}.{}".format( 26 | callback.__self__.__class__.__name__ 27 | if hasattr(callback, "__self__") 28 | else callback.__module__, 29 | callback.__name__, 30 | ) 31 | 32 | def execute(self): 33 | messages = [] 34 | 35 | try: 36 | with timeout( 37 | self._timeout, 38 | exception=WorkerTimeoutError( 39 | "Execution of command {} timed out after {} seconds".format( 40 | self._source, self._timeout 41 | ) 42 | ), 43 | ): 44 | if inspect.isgeneratorfunction(self._callback): 45 | for message in self._callback(*self._args): 46 | messages += message 47 | else: 48 | messages = self._callback(*self._args) 49 | except WorkerTimeoutError as e: 50 | if messages: 51 | logger.log_exception( 52 | _LOGGER, "%s, sending only partial update", e, suppress=True 53 | ) 54 | else: 55 | raise e 56 | 57 | _LOGGER.debug("Execution result of command %s: %s", self._source, messages) 58 | return messages 59 | 60 | def __init__(self, config, mqtt_config): 61 | self._mqtt_callbacks = [] 62 | self._config_commands = [] 63 | self._update_commands = [] 64 | self._scheduler = BackgroundScheduler(timezone=utc) 65 | self._daemons = [] 66 | self._config = config 67 | self._command_timeout = config.get("command_timeout", DEFAULT_COMMAND_TIMEOUT) 68 | self._command_retries = config.get("command_retries", DEFAULT_COMMAND_RETRIES) 69 | self._update_retries = config.get("update_retries", DEFAULT_UPDATE_RETRIES) 70 | self._mqtt = mqtt_config 71 | 72 | def register_workers(self, global_topic_prefix): 73 | for (worker_name, worker_config) in self._config["workers"].items(): 74 | module_obj = importlib.import_module("workers.%s" % worker_name) 75 | klass = getattr(module_obj, "%sWorker" % worker_name.title()) 76 | 77 | command_timeout = worker_config.get( 78 | "command_timeout", self._command_timeout 79 | ) 80 | command_retries = worker_config.get( 81 | "command_retries", self._command_retries 82 | ) 83 | update_retries = worker_config.get( 84 | "update_retries", self._update_retries 85 | ) 86 | worker_obj = klass( 87 | command_timeout, command_retries, update_retries, global_topic_prefix, **worker_config["args"] 88 | ) 89 | 90 | if "sensor_config" in self._config and hasattr(worker_obj, "config"): 91 | _LOGGER.debug( 92 | "Added %s config with a %d seconds timeout", repr(worker_obj), 2 93 | ) 94 | command = self.Command(worker_obj.config, 2, [self._mqtt.availability_topic]) 95 | self._config_commands.append(command) 96 | 97 | if hasattr(worker_obj, "status_update"): 98 | _LOGGER.debug( 99 | "Added %s worker with %d seconds interval and a %d seconds timeout", 100 | repr(worker_obj), 101 | worker_config["update_interval"], 102 | worker_obj.command_timeout, 103 | ) 104 | command = self.Command( 105 | worker_obj.status_update, worker_obj.command_timeout, [] 106 | ) 107 | self._update_commands.append(command) 108 | 109 | if "update_interval" in worker_config: 110 | job_id = "{}_interval_job".format(worker_name) 111 | self._scheduler.add_job( 112 | partial(self._queue_command, command), 113 | "interval", 114 | seconds=worker_config["update_interval"], 115 | id=job_id, 116 | ) 117 | self._mqtt_callbacks.append( 118 | ( 119 | worker_obj.format_topic("update_interval"), 120 | partial(self._update_interval_wrapper, command, job_id), 121 | ) 122 | ) 123 | elif hasattr(worker_obj, "run"): 124 | _LOGGER.debug("Registered %s as daemon", repr(worker_obj)) 125 | self._daemons.append(worker_obj) 126 | else: 127 | raise "%s cannot be initialized, it has to define run or status_update method" % worker_name 128 | 129 | if "topic_subscription" in worker_config: 130 | self._mqtt_callbacks.append( 131 | ( 132 | worker_config["topic_subscription"], 133 | partial(self._on_command_wrapper, worker_obj), 134 | ) 135 | ) 136 | 137 | if "topic_subscription" in self._config: 138 | for (callback_name, options) in self._config["topic_subscription"].items(): 139 | self._mqtt_callbacks.append( 140 | ( 141 | options["topic"], 142 | lambda client, _, c: self._queue_if_matching_payload( 143 | self.Command( 144 | getattr(self, callback_name), self._command_timeout 145 | ), 146 | c.payload, 147 | options["payload"], 148 | ), 149 | ) 150 | ) 151 | 152 | def start(self): 153 | self._mqtt.callbacks_subscription(self._mqtt_callbacks) 154 | 155 | if "sensor_config" in self._config: 156 | self._publish_config() 157 | 158 | self._scheduler.start() 159 | self.update_all() 160 | for daemon in self._daemons: 161 | threading.Thread(target=daemon.run, args=[self._mqtt], daemon=True).start() 162 | 163 | def _queue_if_matching_payload(self, command, payload, expected_payload): 164 | if payload.decode("utf-8") == expected_payload: 165 | self._queue_command(command) 166 | 167 | def update_all(self): 168 | _LOGGER.debug("Updating all workers") 169 | for command in self._update_commands: 170 | self._queue_command(command) 171 | 172 | @staticmethod 173 | def _queue_command(command): 174 | _WORKERS_QUEUE.put(command) 175 | 176 | def _update_interval_wrapper(self, command, job_id, client, userdata, c): 177 | _LOGGER.info("Recieved updated interval for %s with: %s", c.topic, c.payload) 178 | try: 179 | new_interval = int(c.payload) 180 | self._scheduler.remove_job(job_id) 181 | self._scheduler.add_job( 182 | partial(self._queue_command, command), 183 | "interval", 184 | seconds=new_interval, 185 | id=job_id, 186 | ) 187 | except ValueError: 188 | logger.log_exception( 189 | _LOGGER, "Ignoring invalid new interval: %s", c.payload 190 | ) 191 | 192 | def _on_command_wrapper(self, worker_obj, client, userdata, c): 193 | _LOGGER.debug( 194 | "Received command for %s on %s: %s", repr(worker_obj), c.topic, c.payload 195 | ) 196 | global_topic_prefix = userdata["global_topic_prefix"] 197 | topic = ( 198 | c.topic[len(global_topic_prefix + "/"):] 199 | if global_topic_prefix is not None 200 | else c.topic 201 | ) 202 | self._queue_command( 203 | self.Command( 204 | worker_obj.on_command, worker_obj.command_timeout, [topic, c.payload] 205 | ) 206 | ) 207 | 208 | def _publish_config(self): 209 | for command in self._config_commands: 210 | messages = command.execute() 211 | for msg in messages: 212 | msg.topic = "{}/{}".format( 213 | self._config["sensor_config"].get("topic", "homeassistant"), 214 | msg.topic, 215 | ) 216 | msg.retain = self._config["sensor_config"].get("retain", True) 217 | self._mqtt.publish(messages) 218 | -------------------------------------------------------------------------------- /workers_queue.py: -------------------------------------------------------------------------------- 1 | from queue import Queue 2 | 3 | _WORKERS_QUEUE = Queue() 4 | -------------------------------------------------------------------------------- /workers_requirements.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from pathlib import Path 3 | import pkg_resources 4 | import re 5 | import os 6 | 7 | import logger 8 | 9 | _LOGGER = logger.get(__name__) 10 | 11 | 12 | def configured_workers(): 13 | from config import settings 14 | 15 | workers = settings['manager']['workers'] 16 | return _get_requirements(workers) 17 | 18 | 19 | def all_workers(): 20 | workers = map(lambda x: x.stem, Path('./workers').glob('*.py')) 21 | return _get_requirements(workers) 22 | 23 | 24 | def verify(): 25 | requirements = configured_workers() 26 | egg = re.compile(r'.+#egg=(.+)$') 27 | 28 | distributions = [] 29 | for req in requirements: 30 | try: 31 | pkg_resources.Requirement.parse(req) 32 | distributions.append(req) 33 | except (pkg_resources.extern.packaging.requirements.InvalidRequirement, 34 | pkg_resources.RequirementParseError): 35 | match = egg.match(req) 36 | if match: 37 | distributions.append(match.group(1)) 38 | else: 39 | raise 40 | 41 | errors = [] 42 | for dist in distributions: 43 | try: 44 | pkg_resources.require(dist) 45 | except pkg_resources.ResolutionError as e: 46 | errors.append(e.report()) 47 | 48 | if errors: 49 | _LOGGER.error('Error: unsatisfied requirements:') 50 | for error in errors: 51 | _LOGGER.error(' %s', error) 52 | 53 | if os.geteuid() == 0: 54 | prefix = " sudo" 55 | else: 56 | prefix = "" 57 | 58 | _LOGGER.error('You may install those with pip: cd %s ; %s python3 -m pip install `./gateway.py -r configured`', 59 | os.path.dirname(os.path.abspath(__file__)), prefix) 60 | exit(1) 61 | 62 | 63 | def _get_requirements(workers): 64 | requirements = set() 65 | 66 | for worker_name in workers: 67 | module_obj = importlib.import_module("workers.%s" % worker_name) 68 | 69 | try: 70 | requirements.update(module_obj.REQUIREMENTS) 71 | except AttributeError: 72 | continue 73 | 74 | return requirements 75 | --------------------------------------------------------------------------------