├── .gitattributes ├── .github └── workflows │ ├── deploy_dev.yaml │ ├── deploy_prod.yaml │ └── deploy_staging.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── ble2mqtt.json.sample ├── ble2mqtt.png ├── ble2mqtt ├── __init__.py ├── __main__.py ├── __version__.py ├── ble2mqtt.py ├── compat.py ├── devices │ ├── __init__.py │ ├── atom_fast.py │ ├── base.py │ ├── bulb_avea.py │ ├── cooker_redmond.py │ ├── cover_am43.py │ ├── cover_soma.py │ ├── flower_mclh09.py │ ├── flower_miflora.py │ ├── govee.py │ ├── kettle_redmond.py │ ├── kettle_xiaomi.py │ ├── presence.py │ ├── qingping_cgdk2.py │ ├── roidmi_cleaner.py │ ├── ruuvitag.py │ ├── thermostat_ensto.py │ ├── uuids.py │ ├── voltage_bm2.py │ ├── vson_air_wp6003.py │ ├── xiaomi_base.py │ ├── xiaomi_ht.py │ ├── xiaomi_lywsd03.py │ └── xiaomi_lywsd03_atc.py ├── exceptions.py ├── manager.py ├── protocols │ ├── __init__.py │ ├── am43.py │ ├── avea.py │ ├── base.py │ ├── ensto.py │ ├── govee.py │ ├── redmond.py │ ├── ruuvi.py │ ├── soma.py │ ├── wp6003.py │ └── xiaomi.py ├── tasks.py └── utils.py ├── docker_entrypoint.sh ├── mypy.ini ├── requirements.txt ├── selinux ├── README.md ├── apply.docker-bluetoth-policy.sh └── docker_bluetooth.te ├── setup.cfg └── setup.py /.gitattributes: -------------------------------------------------------------------------------- 1 | *.sh text eol=lf -------------------------------------------------------------------------------- /.github/workflows/deploy_dev.yaml: -------------------------------------------------------------------------------- 1 | name: "[SANDBOX] Push Image" 2 | 3 | on: 4 | push: 5 | branches: 6 | - dev 7 | 8 | 9 | jobs: 10 | build-svc: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | name: Checkout repository 15 | 16 | - uses: pmorelli92/github-container-registry-build-push@2.0.0 17 | name: Build and Publish latest service image 18 | with: 19 | github-push-secret: ${{secrets.GITHUB_TOKEN}} 20 | docker-image-name: ble2mqtt 21 | docker-image-tag: dev -------------------------------------------------------------------------------- /.github/workflows/deploy_prod.yaml: -------------------------------------------------------------------------------- 1 | name: "[PRODUCTION] Push Image" 2 | 3 | on: 4 | release: 5 | types: 6 | - released 7 | 8 | jobs: 9 | build-svc: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | name: Checkout repository 14 | 15 | - uses: pmorelli92/github-container-registry-build-push@2.0.0 16 | name: Build and Publish latest service image 17 | with: 18 | github-push-secret: ${{secrets.GITHUB_TOKEN}} 19 | docker-image-name: ble2mqtt 20 | docker-image-tag: ${{ github.event.release.tag_name }} -------------------------------------------------------------------------------- /.github/workflows/deploy_staging.yaml: -------------------------------------------------------------------------------- 1 | name: "[STAGING] Push Image" 2 | 3 | on: 4 | release: 5 | types: 6 | - prereleased 7 | 8 | 9 | jobs: 10 | build-svc: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | name: Checkout repository 15 | 16 | - uses: pmorelli92/github-container-registry-build-push@2.0.0 17 | name: Build and Publish latest service image 18 | with: 19 | github-push-secret: ${{secrets.GITHUB_TOKEN}} 20 | docker-image-name: ble2mqtt 21 | docker-image-tag: ${{ github.event.release.tag_name }}.beta -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | .idea 3 | ble2mqtt.json 4 | build 5 | dist 6 | *.egg-info 7 | tmp 8 | *.swp 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # First stage, build requirements 2 | FROM python:3-slim as builder 3 | 4 | RUN apt-get update && \ 5 | apt-get install gcc git -y && \ 6 | apt-get clean 7 | 8 | WORKDIR /usr/src/app 9 | 10 | # To speed up consecutive builds, copy only requirements and install them 11 | COPY . . 12 | 13 | # Install requirements and ignore warnings for local installation 14 | RUN pip install --user --no-warn-script-location -r requirements.txt 15 | 16 | RUN pip install --user --no-warn-script-location . 17 | 18 | # Second stage 19 | FROM python:3-slim as app 20 | 21 | ENV ROOTLESS_UID 1001 22 | ENV ROOTLESS_GID 1001 23 | ENV ROOTLESS_NAME "rootless" 24 | 25 | # Bluetoothctl is required 26 | RUN apt-get update && \ 27 | apt-get install bluez -y && \ 28 | apt-get clean 29 | 30 | # Copy the local python packages 31 | RUN groupadd --gid ${ROOTLESS_GID} ${ROOTLESS_NAME} && \ 32 | useradd --gid ${ROOTLESS_GID} --uid ${ROOTLESS_UID} -d /home/${ROOTLESS_NAME} ${ROOTLESS_NAME} 33 | 34 | COPY --from=builder /root/.local /home/${ROOTLESS_NAME}/.local 35 | 36 | # Copy run script 37 | COPY ./docker_entrypoint.sh /home/${ROOTLESS_NAME}/docker_entrypoint.sh 38 | RUN chmod +x /home/${ROOTLESS_NAME}/docker_entrypoint.sh 39 | RUN chown -R ${ROOTLESS_UID}:${ROOTLESS_GID} /home/${ROOTLESS_NAME} 40 | 41 | ENV PATH=/home/rootless/.local/bin:$PATH 42 | 43 | USER ${ROOTLESS_NAME} 44 | CMD [ "/home/rootless/docker_entrypoint.sh" ] 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Ivan Belokobylskiy. 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include CHANGELOG.md 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BLE2MQTT 2 | ### Control your Bluetooth devices with smart home 3 | 4 | ![ble2mqtt devices](./ble2mqtt.png) 5 | 6 | ## Supported devices: 7 | 8 | ### Any device 9 | - Any bluetooth device can work as a presence tracker 10 | You can provide `"threshold"` parameter to the device to set the limit in 11 | second when the device is considered away. The default value is 180 seconds. 12 | 13 | ### Kettles 14 | - **Redmond RK-G2xxS series (type: redmond_rk_g200)** 15 | 16 | The default key that is used is `"ffffffffffffffff"` 17 | and can be omitted in the config. 18 | In some cases kettles don't accept it. Just use another 19 | key in the config file for the device: 20 | `"key": "16 random hex numbers"` 21 | 22 | - **Mi Kettle (type: mikettle)** 23 | 24 | Use correct `product_id` for your kettle: 25 | - yunmi.kettle.v1: `131` 26 | - yunmi.kettle.v2: `275` (default) 27 | - yunmi.kettle.v7: `1116` 28 | 29 | ### Multi-Cookers 30 | - **Redmond RMC-M225S, RMC-M227S (type: redmond_rmc_m200)** 31 | 32 | Notes about the key parameter you can read above for the 33 | Redmond kettles. 34 | *Other RMC multi-cookers may need 35 | adjustments for the list of available programs, it depends 36 | on the device panel.* 37 | 38 | ### Humidity sensors 39 | - **Xiaomi MJ_HT_V1 (type: xiaomihtv1)** 40 | - **Xiaomi LYWSD02MMC (type: xiaomihtv1)** 41 | - **Xiaomi LYWSD03MMC (type: xiaomilywsd)** (due to the connection to the device on 42 | every data fetch, it consumes more battery power. Flashing to the custom 43 | firmware is recommended) 44 | - **Xiaomi LYWSD03MMC with custom ATC firmware (xiaomilywsd_atc)** 45 | - supported both atc1441 and pvvx formats 46 | - **Qingping CGDK2 (type: qingpingCGDK2)** 47 | - **RuuviTag (type: ruuvitag)** 48 | - **RuuviTag Pro 2in1 (type: ruuvitag_pro_2in1)** 49 | - **RuuviTag Pro 3in1 (type: ruuvitag_pro_3in1)** 50 | - **RuuviTag Pro 4in1 (type: ruuvitag)** 51 | - **Govee H5074, H5075 (type: govee_ht)** 52 | 53 | ### Air sensors 54 | - **Vson WP6003 (type: wp6003)** 55 | 56 | ### Shades and Blinds 57 | - **Generic AM43 (type: am43)** 58 | 59 | Manufacturer can be A-OK, Zemismart, etc. 60 | - **Soma Shades (type: soma_shades)** 61 | 62 | ### Bulbs 63 | - **Avea RGBW bulbs (type: avea_rgbw)** 64 | 65 | ### Dosimeters 66 | - **Atom Fast (type: atomfast)** 67 | 68 | ### Heaters 69 | - **Ensto EPHBEBT10PR, EPHBEBT15PR (type: ensto_thermostat)** 70 | 71 | These devices require [manual pairing](#manual-pairing-in-linux). 72 | After the device is paired on the host device, see the logs for the `key` and 73 | put it to the config. 74 | 75 | The adapter uses holiday mode to control temperature as thermostat. You cannot 76 | use this feature in the official app while ble2mqtt is working. 77 | 78 | ### Vacuum cleaners 79 | 80 | - **Roidmi NEX2 Pro (type: roidmi_cleaner)** 81 | 82 | ### Battery voltage meters 83 | 84 | - **BM2 car battery voltage meter (type: voltage_bm2)** 85 | 86 | 87 | ### Plant sensors: 88 | 89 | - **LifeControl MCLH-09 (type: mclh09)** 90 | 91 | optionally, polling interval can be configured with `interval` parameter in seconds 92 | - **Xiaomi Mi Flora (type: miflora)** 93 | 94 | optionally, polling interval can be configured with `interval` parameter in seconds 95 | 96 | By default, a device works in the passive mode without connection by 97 | listening to advertisement packets from a device. 98 | To use connection to the device provide `"passive": false` parameter. 99 | 100 | **Supported devices in passive mode:** 101 | - Xiaomi MJ_HT_V1 (xiaomihtv1) 102 | - Xiaomi LYWSD03MMC with custom ATC firmware (xiaomilywsd_atc) 103 | - RuuviTag (ruuvitag/ruuvitag_pro_2in1/ruuvitag_pro_3in1) 104 | - Govee temperature/humidity sensors (govee_ht) 105 | - Any device as presence tracker 106 | 107 | ## Manual pairing in Linux 108 | 109 | Some devices (e.g. Ensto heaters) require paired connection to work with it. 110 | You need to pair the device with linux machine before using it. 111 | 112 | Find out MAC addresses of your devices. Put the device in pairing mode if it is supported. 113 | 114 | Open console and run `bluetoothctl` command. It is a command line tool to work with BLE devices. 115 | Wait for the prompt 116 | 117 | ``` 118 | [bluetooth]# 119 | ``` 120 | 121 | Print a command to enable scanning. Linux must know the device is present before pairing. 122 | 123 | ``` 124 | [bluetooth]# scan on 125 | ``` 126 | 127 | Wait for MAC address of the device appears in the list of found devices. 128 | Print a pairing command (replace MAC address to the one from your device) 129 | 130 | ``` 131 | [bluetooth]# pair 90:fd:00:00:00:01 132 | ``` 133 | 134 | On successful pairing you'll see a message: 135 | 136 | ``` 137 | [CHG] Device 90:FD:00:00:00:01 Paired: yes 138 | Pairing successful 139 | ``` 140 | You can proceed with the next configuration steps now. 141 | 142 | 143 | ## Selinux issues 144 | 145 | If using SELinux and you are experience issues, see [README.md](selinux/README.md). 146 | 147 | ### Known issues: 148 | - *High cpu usage due to underlying library to work with bluetooth* 149 | 150 | **Use this software at your own risk.** 151 | 152 | ## Configuration 153 | 154 | Default config should be located in `/etc/ble2mqtt.json` or 155 | can be overridden with `BLE2MQTT_CONFIG` environment variable. 156 | 157 | Example run command: 158 | 159 | ```sh 160 | BLE2MQTT_CONFIG=./ble2mqtt.json ble2mqtt 161 | ``` 162 | 163 | The configuration file is a JSON with the following content: 164 | 165 | ```json 166 | { 167 | "mqtt_host": "localhost", 168 | "mqtt_port": 1883, 169 | "mqtt_user": "", 170 | "mqtt_password": "", 171 | "log_level": "INFO", 172 | 173 | "// remove this comment. Set next line to true if you have HA <2024.4": "", 174 | "legacy_color_mode": false, 175 | "devices": [ 176 | { 177 | "address": "11:22:33:aa:cc:aa", 178 | "type": "presence" 179 | }, 180 | { 181 | "address": "11:22:33:aa:bb:cc", 182 | "type": "redmond_rk_g200", 183 | "key": "ffffffffffffffff" 184 | }, 185 | { 186 | "address": "11:22:33:aa:bb:c0", 187 | "type": "redmond_rmc_m200", 188 | "key": "ffffffffffffffff" 189 | }, 190 | { 191 | "address": "11:22:33:aa:bb:c1", 192 | "type": "ensto_thermostat", 193 | "# see logs after pairing and put the key to config": "", 194 | "key": "00112233" 195 | }, 196 | { 197 | "address": "11:22:33:aa:bb:cd", 198 | "type": "mikettle", 199 | "product_id": 275 200 | }, 201 | { 202 | "address": "11:22:33:aa:bb:de", 203 | "type": "am43" 204 | }, 205 | { 206 | "address": "11:22:33:aa:bb:dd", 207 | "type": "xiaomihtv1", 208 | "interval": 60 209 | }, 210 | { 211 | "address": "11:22:34:aa:bb:dd", 212 | "type": "xiaomihtv1", 213 | "passive": false 214 | }, 215 | { 216 | "address": "11:22:33:aa:bb:ee", 217 | "type": "xiaomilywsd" 218 | }, 219 | { 220 | "address": "11:22:33:aa:bb:ff", 221 | "type": "xiaomilywsd_atc", 222 | "interval": 60 223 | }, 224 | { 225 | "address": "11:22:33:aa:aa:aa", 226 | "type": "atomfast" 227 | }, 228 | { 229 | "address": "11:22:33:aa:aa:bb", 230 | "type": "voltage_bm2" 231 | }, 232 | { 233 | "address": "11:22:33:aa:aa:bc", 234 | "type": "mclh09", 235 | "interval": 600 236 | }, 237 | { 238 | "address": "11:22:33:aa:aa:bd", 239 | "type": "miflora", 240 | "interval": 500 241 | }, 242 | { 243 | "address": "11:22:33:aa:aa:be", 244 | "type": "ruuvitag", 245 | "interval": 60 246 | }, 247 | { 248 | "address": "11:22:33:aa:aa:0a", 249 | "type": "roidmi_cleaner" 250 | } 251 | ] 252 | } 253 | ``` 254 | 255 | You can omit a line, then default value will be used. 256 | 257 | Extra configuration parameters: 258 | - `"base_topic"`- the default value is 'ble2mqtt' 259 | - `"mqtt_prefix"`- a prefix to distinguish ble devices from other instances and 260 | programs. The default value is 'b2m_'. 261 | - `"hci_adapter"` - an adapter to use. The default value is "hci0" 262 | - `"legacy_color_mode"` - set to true if you have Home Assistant version < 2024.4. For example, if you use HomeAssistant on OpenWrt script. 263 | 264 | Devices accept `friendly_name` parameter to replace mac address in device 265 | names for Home Assistant. 266 | 267 | 268 | ## Systemd unit file to start on boot 269 | 270 | Put the following content to the unit file `/etc/systemd/system/ble2mqtt.service` 271 | 272 | ``` 273 | [Unit] 274 | Description=ble2mqtt bridge 275 | 276 | [Service] 277 | Type=Simple 278 | ExecStart=/usr/local/bin/ble2mqtt 279 | User=ble2mqtt 280 | Group=ble2mqtt 281 | Wants=bluetooth.target 282 | 283 | [Install] 284 | WantedBy=multi-user.target 285 | ``` 286 | 287 | The user and group should match the owner and group of the configuration file /etc/ble2mqtt.json. 288 | 289 | Afterwards you simply have to enable and start the service: 290 | 291 | ```sh 292 | sudo systemctl daemon-reload 293 | sudo systemctl enable ble2mqtt 294 | sudo systemctl start ble2mqtt 295 | ``` 296 | 297 | ## Installation on OpenWRT 298 | 299 | ### OpenWRT 23.05 and later 300 | 301 | Create the configuration file in /etc/ble2mqtt.json and 302 | append your devices. 303 | 304 | Execute the following commands in the terminal: 305 | 306 | ```sh 307 | opkg update 308 | opkg install python3-ble2mqtt 309 | /etc/init.d/ble2mqtt enable 310 | /etc/init.d/ble2mqtt start 311 | ``` 312 | 313 | ### OpenWRT 22.03 and earlier 314 | 315 | Execute the following commands in the terminal: 316 | 317 | ```sh 318 | opkg update 319 | opkg install python3-pip python3-asyncio 320 | pip3 install "bleak>=0.11.0" 321 | pip3 install -U ble2mqtt 322 | ``` 323 | 324 | Create the configuration file in /etc/ble2mqtt.json and 325 | append your devices. 326 | 327 | Bluetooth must be turned on. 328 | 329 | ```sh 330 | hciconfig hci0 up 331 | ``` 332 | 333 | Run the service in background 334 | 335 | ```sh 336 | ble2mqtt 2> /tmp/ble2mqtt.log & 337 | ``` 338 | 339 | Add a service script to start: 340 | 341 | ```sh 342 | cat < /etc/init.d/ble2mqtt 343 | #!/bin/sh /etc/rc.common 344 | 345 | START=98 346 | USE_PROCD=1 347 | 348 | start_service() 349 | { 350 | procd_open_instance 351 | 352 | procd_set_param env BLE2MQTT_CONFIG=/etc/ble2mqtt.json 353 | procd_set_param command /usr/bin/ble2mqtt 354 | procd_set_param stdout 1 355 | procd_set_param stderr 1 356 | procd_close_instance 357 | } 358 | EOF 359 | chmod +x /etc/init.d/ble2mqtt 360 | /etc/init.d/ble2mqtt enable 361 | /etc/init.d/ble2mqtt start 362 | ``` 363 | 364 | ## Running on Xiaomi Zigbee Gateway 365 | 366 | Due to small CPU power and increasing number of messages from bluetoothd 367 | it is recommended to do several workarounds: 368 | 369 | 1. Use passive mode for those sensors for which this is possible. E.g. use 370 | custom ATC firmware for lywsd03mmc sensors 371 | 1. Restart `bluetoothd` daily and restart ble2mqtt several times a day to 372 | reduce increasing CPU usage. 373 | Put the following lines to the `/etc/crontabs/root` 374 | 375 | ``` 376 | 10 0,7,17 * * * /etc/init.d/ble2mqtt restart 377 | 1 4,14 * * * /etc/init.d/bluetoothd restart 378 | ``` 379 | 380 | ## Running in Container 381 | 382 | Build the image as: 383 | 384 | ```sh 385 | podman build -t ble2mqtt:dev . 386 | ``` 387 | 388 | Start the container and share the config file and DBus for Bluetooth connectivity: 389 | ```sh 390 | podman run \ 391 | -d \ 392 | --net=host \ 393 | -v $PWD/ble2mqtt.json.sample:/etc/ble2mqtt.json:z \ 394 | -v /var/run/dbus:/var/run/dbus:z \ 395 | ble2mqtt:dev 396 | ``` 397 | 398 | Instead of sharing `/var/run/dbus`, you can export `DBUS_SYSTEM_BUS_ADDRESS`. 399 | 400 | NOTE: `--net=host` is required as it needs to use the bluetooth interface 401 | 402 | NOTE: `podman` is the same as `docker` 403 | 404 | 405 | ## Running in Container FULLy 406 | 407 | > **ATTENTION:** Make sure `bluez` is not running (or not intalled) on your host. 408 | 409 | Build the image as: 410 | 411 | ```sh 412 | docker build -t ble2mqtt:dev . 413 | ``` 414 | 415 | Start the container and share the config file: 416 | ```sh 417 | docker run \ 418 | -d \ 419 | --net=host \ 420 | --cap-add=NET_ADMIN \ 421 | -v $PWD/ble2mqtt.json.sample:/etc/ble2mqtt.json:ro \ 422 | ble2mqtt:dev 423 | ``` 424 | 425 | Docker compose: 426 | ```yaml 427 | version: '3.7' 428 | services: 429 | 430 | ble2mqtt: 431 | image: ble2mqtt:dev 432 | build: ./ble2mqtt 433 | hostname: ble2mqtt 434 | restart: always 435 | environment: 436 | - TZ=Asia/Yekaterinburg 437 | volumes: 438 | - ./ble2mqtt/ble2mqtt.json:/etc/ble2mqtt.json:ro 439 | network_mode: host 440 | cap_add: 441 | - NET_ADMIN 442 | 443 | ``` 444 | 445 | You do not need to share `/var/run/dbus`, because `dbus` will start in the container. 446 | 447 | NOTE: `--net=host` and `--cap-add=NET_ADMIN` is required as it needs to use and control the bluetooth interface 448 | -------------------------------------------------------------------------------- /ble2mqtt.json.sample: -------------------------------------------------------------------------------- 1 | { 2 | "mqtt_host": "localhost", 3 | "mqtt_user": "", 4 | "mqtt_password": "", 5 | "legacy_color_mode": false, 6 | "devices": [ 7 | { 8 | "address": "11:22:33:aa:cc:aa", 9 | "type": "presence" 10 | }, 11 | { 12 | "address": "11:22:33:aa:bb:cc", 13 | "type": "redmond_rk_g200", 14 | "key": "ffffffffffffffff" 15 | }, 16 | { 17 | "address": "11:22:33:aa:bb:cd", 18 | "type": "mikettle", 19 | "product_id": 275 20 | }, 21 | { 22 | "address": "11:22:33:aa:bb:ce", 23 | "type": "redmond_rmc_m200", 24 | "key": "ffffffffffffffee" 25 | }, 26 | { 27 | "address": "11:22:33:aa:bb:c1", 28 | "type": "ensto_thermostat", 29 | "key": "00112233" 30 | }, 31 | { 32 | "address": "11:22:33:aa:bb:de", 33 | "type": "am43" 34 | }, 35 | { 36 | "address": "11:22:33:aa:bb:ed", 37 | "type": "soma_shades" 38 | }, 39 | { 40 | "address": "11:22:33:aa:bb:dd", 41 | "type": "xiaomihtv1" 42 | }, 43 | { 44 | "address": "11:22:34:aa:bb:dd", 45 | "type": "xiaomihtv1", 46 | "passive": false 47 | }, 48 | { 49 | "address": "11:22:33:aa:bb:ee", 50 | "type": "xiaomilywsd" 51 | }, 52 | { 53 | "address": "11:22:33:aa:bb:ff", 54 | "type": "xiaomilywsd_atc" 55 | } 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /ble2mqtt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devbis/ble2mqtt/1e95f1e3e0c324fbd9eb294fa7812d8b3a47f702/ble2mqtt.png -------------------------------------------------------------------------------- /ble2mqtt/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devbis/ble2mqtt/1e95f1e3e0c324fbd9eb294fa7812d8b3a47f702/ble2mqtt/__init__.py -------------------------------------------------------------------------------- /ble2mqtt/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio as aio 2 | import json 3 | import logging 4 | import os 5 | 6 | from ble2mqtt.__version__ import VERSION 7 | from ble2mqtt.ble2mqtt import Ble2Mqtt 8 | from ble2mqtt.compat import get_bleak_version 9 | 10 | from .devices import registered_device_types 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | is_shutting_down: aio.Lock = aio.Lock() 15 | 16 | 17 | async def shutdown(loop, service: Ble2Mqtt, signal=None): 18 | """Cleanup tasks tied to the service's shutdown.""" 19 | if is_shutting_down.locked(): 20 | return 21 | async with is_shutting_down: 22 | if signal: 23 | _LOGGER.info(f"Received exit signal {signal.name}...") 24 | _LOGGER.info("Closing ble2mqtt service") 25 | await service.close() 26 | tasks = [t for t in aio.all_tasks() if t is not aio.current_task()] 27 | 28 | [task.cancel() for task in tasks] 29 | 30 | _LOGGER.info(f"Cancelling {len(tasks)} outstanding tasks") 31 | try: 32 | await aio.wait_for( 33 | aio.gather(*tasks, return_exceptions=True), 34 | timeout=10, 35 | ) 36 | except (Exception, aio.CancelledError): 37 | _LOGGER.exception(f'Cancelling caused error: {tasks}') 38 | loop.stop() 39 | 40 | 41 | def handle_exception(loop, context, service): 42 | _LOGGER.error(f"Caught exception: {context}") 43 | loop.default_exception_handler(context) 44 | exception_str = context.get('task') or context.get('future') or '' 45 | exception = context.get('exception') 46 | if 'BleakClientBlueZDBus._disconnect_monitor()' in \ 47 | str(repr(exception_str)): 48 | # There is some problem when Bleak waits for disconnect event 49 | # and asyncio destroys the task and raises 50 | # Task was destroyed but it is pending! 51 | # Need further investigating. 52 | # Skip this exception for now. 53 | _LOGGER.info("Ignore this exception.") 54 | return 55 | 56 | if "'NoneType' object has no attribute" in \ 57 | str(repr(exception)): 58 | # lambda _: self._disconnecting_event.set() 59 | # AttributeError: 'NoneType' object has no attribute 'set' 60 | # await self._disconnect_monitor_event.wait() 61 | # AttributeError: 'NoneType' object has no attribute 'wait' 62 | _LOGGER.info("Ignore this exception.") 63 | return 64 | 65 | if isinstance(exception, BrokenPipeError): 66 | # task = asyncio.ensure_future(self._cleanup_all()) 67 | # in bluezdbus/client.py: _parse_msg() can fail while remove_match() 68 | _LOGGER.info("Ignore this exception.") 69 | return 70 | 71 | _LOGGER.info("Shutting down...") 72 | aio.create_task(shutdown(loop, service)) 73 | 74 | 75 | def get_ssl_context(config): 76 | if config.get('mqtt_tls') != "True": 77 | return None 78 | import ssl 79 | ca_cert = config.get('mqtt_ca') 80 | client_cert = config.get('mqtt_cert') 81 | client_keyfile = config.get('mqtt_key') 82 | client_keyfile_password = config.get('mqtt_key_password') 83 | ca_verify = config.get('mqtt_ca_verify') != "False" 84 | context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) 85 | if ca_cert is not None: 86 | context.load_verify_locations(ca_cert) 87 | else: 88 | context.load_default_certs() 89 | context.check_hostname = ca_verify 90 | context.verify_mode = ssl.CERT_REQUIRED if ca_verify else ssl.CERT_NONE 91 | if client_keyfile is not None: 92 | context.load_cert_chain( 93 | client_cert, 94 | client_keyfile, 95 | client_keyfile_password, 96 | ) 97 | return context 98 | 99 | 100 | async def amain(config): 101 | loop = aio.get_running_loop() 102 | 103 | service = Ble2Mqtt( 104 | reconnection_interval=10, 105 | loop=loop, 106 | host=config['mqtt_host'], 107 | port=config['mqtt_port'], 108 | user=config.get('mqtt_user'), 109 | password=config.get('mqtt_password'), 110 | ssl=get_ssl_context(config), 111 | base_topic=config['base_topic'], 112 | mqtt_config_prefix=config['mqtt_config_prefix'], 113 | hci_adapter=config['hci_adapter'], 114 | legacy_color_mode=config['legacy_color_mode'], 115 | ) 116 | 117 | loop.set_exception_handler( 118 | lambda *args: handle_exception(*args, service=service), 119 | ) 120 | 121 | devices = config.get('devices') or [] 122 | for device in devices: 123 | try: 124 | mac = device.pop('address') 125 | typ = device.pop('type') 126 | except (ValueError, IndexError): 127 | continue 128 | klass = registered_device_types[typ] 129 | service.register( 130 | klass, 131 | mac=mac, 132 | loop=loop, 133 | **device, 134 | ) 135 | 136 | try: 137 | await service.start() 138 | except KeyboardInterrupt: 139 | _LOGGER.info('Exiting...') 140 | finally: 141 | await service.close() 142 | 143 | 144 | def main(): 145 | os.environ.setdefault('BLE2MQTT_CONFIG', '/etc/ble2mqtt.json') 146 | config = {} 147 | if os.path.exists(os.environ['BLE2MQTT_CONFIG']): 148 | try: 149 | with open(os.environ['BLE2MQTT_CONFIG'], 'r') as f: 150 | config = json.load(f) 151 | except FileNotFoundError: 152 | pass 153 | 154 | config = { 155 | 'mqtt_host': 'localhost', 156 | 'mqtt_port': 1883, 157 | 'base_topic': 'ble2mqtt', 158 | 'mqtt_config_prefix': 'b2m_', 159 | 'log_level': 'INFO', 160 | 'hci_adapter': 'hci0', 161 | 'legacy_color_mode': False, 162 | **config, 163 | } 164 | 165 | logging.basicConfig( 166 | format='%(asctime)s %(levelname)s: %(message)s', 167 | level=config['log_level'].upper(), 168 | datefmt='%Y-%m-%d %H:%M:%S', 169 | ) 170 | # logging.getLogger('bleak.backends.bluezdbus.scanner').setLevel('INFO') 171 | _LOGGER.info( 172 | 'Starting BLE2MQTT version %s, bleak %s, adapter %s', 173 | VERSION, 174 | get_bleak_version(), 175 | config["hci_adapter"] 176 | ) 177 | 178 | try: 179 | aio.run(amain(config), debug=(config['log_level'].upper() == 'DEBUG')) 180 | except KeyboardInterrupt: 181 | pass 182 | _LOGGER.info('Bye.') 183 | 184 | 185 | if __name__ == '__main__': 186 | main() 187 | -------------------------------------------------------------------------------- /ble2mqtt/__version__.py: -------------------------------------------------------------------------------- 1 | VERSION = '0.2.4' 2 | -------------------------------------------------------------------------------- /ble2mqtt/ble2mqtt.py: -------------------------------------------------------------------------------- 1 | import asyncio as aio 2 | import json 3 | import logging 4 | import typing as ty 5 | from uuid import getnode 6 | 7 | import aio_mqtt 8 | from bleak.backends.device import BLEDevice 9 | 10 | from .compat import get_scanner 11 | from .devices.base import ConnectionMode, Device, done_callback 12 | from .exceptions import (ListOfConnectionErrors, handle_ble_exceptions, 13 | restart_bluetooth) 14 | from .manager import DeviceManager 15 | from .tasks import handle_returned_tasks, run_tasks_and_cancel_on_first_return 16 | 17 | try: 18 | from bleak import AdvertisementData 19 | except ImportError: 20 | AdvertisementData = ty.Any 21 | 22 | _LOGGER = logging.getLogger(__name__) 23 | 24 | BRIDGE_STATE_TOPIC = 'state' 25 | 26 | 27 | class Ble2Mqtt: 28 | TOPIC_ROOT = 'ble2mqtt' 29 | BRIDGE_TOPIC = 'bridge' 30 | 31 | def __init__( 32 | self, 33 | ssl, 34 | host: str, 35 | port: int = None, 36 | user: ty.Optional[str] = None, 37 | password: ty.Optional[str] = None, 38 | reconnection_interval: int = 10, 39 | loop: ty.Optional[aio.AbstractEventLoop] = None, 40 | *, 41 | hci_adapter: str, 42 | base_topic, 43 | mqtt_config_prefix, 44 | legacy_color_mode, 45 | ) -> None: 46 | self._hci_adapter = hci_adapter 47 | self._mqtt_host = host 48 | self._mqtt_port = port 49 | self._mqtt_user = user 50 | self._mqtt_password = password 51 | self._ssl = ssl 52 | self._base_topic = base_topic 53 | self._mqtt_config_prefix = mqtt_config_prefix 54 | self._legacy_color_mode = legacy_color_mode 55 | 56 | self._reconnection_interval = reconnection_interval 57 | self._loop = loop or aio.get_event_loop() 58 | 59 | self._mqtt_client = aio_mqtt.Client( 60 | client_id_prefix=f'{base_topic}_', 61 | loop=self._loop, 62 | ) 63 | 64 | self._device_managers: ty.Dict[Device, DeviceManager] = {} 65 | 66 | self.availability_topic = '/'.join(( 67 | self._base_topic, 68 | self.BRIDGE_TOPIC, 69 | BRIDGE_STATE_TOPIC, 70 | )) 71 | 72 | self.device_registry: ty.List[Device] = [] 73 | 74 | async def start(self): 75 | result = await run_tasks_and_cancel_on_first_return( 76 | self._loop.create_task(self._connect_mqtt_forever()), 77 | self._loop.create_task(self._handle_messages()), 78 | ) 79 | for t in result: 80 | await t 81 | 82 | async def close(self) -> None: 83 | for device, manager in self._device_managers.items(): 84 | await manager.close() 85 | 86 | if self._mqtt_client.is_connected: 87 | try: 88 | await self._mqtt_client.disconnect() 89 | except aio.CancelledError: 90 | raise 91 | except aio_mqtt.ConnectionClosedError: 92 | pass 93 | except Exception as e: 94 | _LOGGER.warning(f'Error on MQTT disconnecting: {repr(e)}') 95 | 96 | def register(self, device_class: ty.Type[Device], *args, **kwargs): 97 | device = device_class(*args, **kwargs) 98 | if not device: 99 | return 100 | if not device.is_passive and not device.SUPPORT_ACTIVE: 101 | raise NotImplementedError( 102 | f'Device {device.dev_id} doesn\'t support active mode', 103 | ) 104 | assert device.is_passive or device.ACTIVE_CONNECTION_MODE in ( 105 | ConnectionMode.ACTIVE_POLL_WITH_DISCONNECT, 106 | ConnectionMode.ACTIVE_KEEP_CONNECTION, 107 | ConnectionMode.ON_DEMAND_CONNECTION, 108 | ) 109 | self.device_registry.append(device) 110 | 111 | @property 112 | def subscribed_topics(self): 113 | return [ 114 | '/'.join((self._base_topic, topic)) 115 | for device in self.device_registry 116 | for topic in device.subscribed_topics 117 | ] 118 | 119 | async def _handle_messages(self) -> None: 120 | async for message in self._mqtt_client.delivered_messages( 121 | f'{self._base_topic}/#', 122 | ): 123 | _LOGGER.debug(message) 124 | while True: 125 | if message.topic_name not in self.subscribed_topics: 126 | await aio.sleep(0) 127 | continue 128 | 129 | prefix = f'{self._base_topic}/' 130 | if message.topic_name.startswith(prefix): 131 | topic_wo_prefix = message.topic_name[len(prefix):] 132 | else: 133 | topic_wo_prefix = prefix 134 | for _device in self.device_registry: 135 | if topic_wo_prefix in _device.subscribed_topics: 136 | device = _device 137 | break 138 | else: 139 | raise NotImplementedError('Unknown topic') 140 | await aio.sleep(0) 141 | if not device.client.is_connected: 142 | _LOGGER.warning( 143 | f'Received topic {topic_wo_prefix} ' 144 | f'with {message.payload} ' 145 | f' but {device.client} is offline', 146 | ) 147 | await aio.sleep(5) 148 | continue 149 | 150 | try: 151 | value = json.loads(message.payload) 152 | except ValueError: 153 | value = message.payload.decode() 154 | 155 | await device.add_incoming_message(topic_wo_prefix, value) 156 | break 157 | 158 | await aio.sleep(1) 159 | 160 | async def stop_device_manage_tasks(self): 161 | for manager in self._device_managers.values(): 162 | try: 163 | await manager.close() 164 | except aio.CancelledError: 165 | raise 166 | except Exception: 167 | _LOGGER.exception( 168 | f'Problem on closing dev manager {manager.device}') 169 | 170 | def device_detection_callback(self, device: BLEDevice, 171 | advertisement_data: AdvertisementData): 172 | for reg_device in self.device_registry: 173 | if reg_device.mac.lower() == device.address.lower(): 174 | if hasattr(advertisement_data, 'rssi'): 175 | rssi = advertisement_data.rssi 176 | else: 177 | rssi = device.rssi 178 | if rssi: 179 | # update rssi for all devices if available 180 | reg_device.rssi = rssi 181 | 182 | if reg_device in self._device_managers: 183 | self._device_managers[reg_device].set_scanned_device(device) 184 | 185 | if reg_device.is_passive: 186 | if device.name: 187 | reg_device._model = device.name 188 | reg_device.handle_advert(device, advertisement_data) 189 | else: 190 | _LOGGER.debug( 191 | f'active device seen: {reg_device} ' 192 | f'{advertisement_data}', 193 | ) 194 | reg_device.set_advertisement_seen() 195 | 196 | async def scan_devices_task(self): 197 | empty_scans = 0 198 | while True: 199 | # 10 empty scans in a row means that bluetooth restart is required 200 | if empty_scans >= 10: 201 | empty_scans = 0 202 | await restart_bluetooth(self._hci_adapter) 203 | 204 | try: 205 | async with handle_ble_exceptions(self._hci_adapter): 206 | scanner = get_scanner( 207 | self._hci_adapter, 208 | self.device_detection_callback, 209 | ) 210 | try: 211 | await aio.wait_for(scanner.start(), 10) 212 | except aio.TimeoutError: 213 | _LOGGER.error('Scanner start failed with timeout') 214 | await aio.sleep(3) 215 | devices = scanner.discovered_devices 216 | await scanner.stop() 217 | if not devices: 218 | empty_scans += 1 219 | else: 220 | empty_scans = 0 221 | _LOGGER.debug(f'found {len(devices)} devices: {devices}') 222 | except KeyboardInterrupt: 223 | raise 224 | except aio.IncompleteReadError: 225 | raise 226 | except ListOfConnectionErrors as e: 227 | _LOGGER.exception(e) 228 | empty_scans += 1 229 | await aio.sleep(1) 230 | 231 | async def _run_device_tasks(self, mqtt_connection_fut: aio.Future) -> None: 232 | for dev in self.device_registry: 233 | self._device_managers[dev] = \ 234 | DeviceManager( 235 | dev, 236 | hci_adapter=self._hci_adapter, 237 | mqtt_client=self._mqtt_client, 238 | base_topic=self._base_topic, 239 | config_prefix=self._mqtt_config_prefix, 240 | global_availability_topic=self.availability_topic, 241 | legacy_color_mode=self._legacy_color_mode, 242 | ) 243 | _LOGGER.debug("Wait for network interruptions...") 244 | 245 | device_tasks = [ 246 | manager.run_task() 247 | for manager in self._device_managers.values() 248 | ] 249 | scan_task = self._loop.create_task(self.scan_devices_task()) 250 | scan_task.add_done_callback(done_callback) 251 | device_tasks.append(scan_task) 252 | 253 | futs = [ 254 | mqtt_connection_fut, 255 | *device_tasks, 256 | ] 257 | 258 | finished = await run_tasks_and_cancel_on_first_return( 259 | *futs, 260 | ignore_futures=[mqtt_connection_fut], 261 | ) 262 | 263 | finished_managers = [] 264 | for d, m in self._device_managers.items(): 265 | if m.manage_task not in finished: 266 | await m.close() 267 | else: 268 | finished_managers.append(m) 269 | 270 | for m in finished_managers: 271 | await m.close() 272 | 273 | # when mqtt server disconnects, multiple tasks can raise 274 | # exceptions. We must fetch all of them 275 | finished = [t for t in futs if t.done() and not t.cancelled()] 276 | await handle_returned_tasks(*finished) 277 | 278 | async def _connect_mqtt_forever(self) -> None: 279 | dev_id = hex(getnode()) 280 | while True: 281 | try: 282 | mqtt_connection = await aio.wait_for(self._mqtt_client.connect( 283 | host=self._mqtt_host, 284 | port=self._mqtt_port, 285 | username=self._mqtt_user, 286 | password=self._mqtt_password, 287 | ssl=self._ssl, 288 | client_id=f'ble2mqtt_{dev_id}', 289 | will_message=aio_mqtt.PublishableMessage( 290 | topic_name=self.availability_topic, 291 | payload='offline', 292 | qos=aio_mqtt.QOSLevel.QOS_1, 293 | retain=True, 294 | ), 295 | ), timeout=self._reconnection_interval) 296 | _LOGGER.info(f'Connected to {self._mqtt_host}') 297 | await self._mqtt_client.publish( 298 | aio_mqtt.PublishableMessage( 299 | topic_name=self.availability_topic, 300 | payload='online', 301 | qos=aio_mqtt.QOSLevel.QOS_1, 302 | retain=True, 303 | ), 304 | ) 305 | await self._run_device_tasks(mqtt_connection.disconnect_reason) 306 | except aio.TimeoutError: 307 | logging.warning('Cannot connect to MQTT broker') 308 | except (aio.CancelledError, KeyboardInterrupt): 309 | if self._mqtt_client.is_connected(): 310 | await self._mqtt_client.publish( 311 | aio_mqtt.PublishableMessage( 312 | topic_name=self.availability_topic, 313 | payload='offline', 314 | qos=aio_mqtt.QOSLevel.QOS_0, 315 | retain=True, 316 | ), 317 | ) 318 | raise 319 | except Exception: 320 | _LOGGER.exception( 321 | "Connection lost. Will retry in %d seconds.", 322 | self._reconnection_interval, 323 | ) 324 | try: 325 | await self.stop_device_manage_tasks() 326 | except aio.CancelledError: 327 | raise 328 | except Exception: 329 | _LOGGER.exception('Exception in _connect_forever()') 330 | try: 331 | await self._mqtt_client.disconnect() 332 | except aio.CancelledError: 333 | raise 334 | except Exception: 335 | _LOGGER.error('Disconnect from MQTT broker error') 336 | await aio.sleep(self._reconnection_interval) 337 | -------------------------------------------------------------------------------- /ble2mqtt/compat.py: -------------------------------------------------------------------------------- 1 | try: 2 | from importlib import metadata 3 | except ImportError: 4 | metadata = None 5 | import sys 6 | 7 | import bleak 8 | 9 | 10 | def get_loop_param(loop): 11 | if sys.version_info >= (3, 8): 12 | return {} 13 | return {'loop': loop} 14 | 15 | 16 | def get_bleak_version(): 17 | # returns None if version info is messed up 18 | if not metadata: 19 | return None 20 | return metadata.version('bleak') 21 | 22 | 23 | bleak_version = get_bleak_version() 24 | 25 | 26 | def get_scanner(hci_adapter: str, detection_callback) -> bleak.BleakScanner: 27 | if bleak_version and bleak_version < '0.18': 28 | scanner = bleak.BleakScanner(adapter=hci_adapter) 29 | scanner.register_detection_callback(detection_callback) 30 | else: 31 | scanner = bleak.BleakScanner( 32 | adapter=hci_adapter, 33 | detection_callback=detection_callback, 34 | ) 35 | 36 | return scanner 37 | -------------------------------------------------------------------------------- /ble2mqtt/devices/__init__.py: -------------------------------------------------------------------------------- 1 | from .atom_fast import AtomFast # noqa: F401 2 | from .base import registered_device_types # noqa: F401 3 | from .bulb_avea import AveaBulb # noqa: F401 4 | from .cooker_redmond import RedmondCooker # noqa: F401 5 | from .cover_am43 import AM43Cover # noqa: F401 6 | from .cover_soma import SomaCover # noqa: F401 7 | from .flower_mclh09 import FlowerMonitorMCLH09 # noqa: F401 8 | from .flower_miflora import FlowerMonitorMiFlora # noqa: F401 9 | from .govee import GoveeTemperature # noqa: F401 10 | from .kettle_redmond import RedmondKettle # noqa: F401 11 | from .kettle_xiaomi import XiaomiKettle # noqa: F401 12 | from .presence import Presence # noqa: F401 13 | from .qingping_cgdk2 import QingpingTempRHMonitorLite # noqa: F401 14 | from .roidmi_cleaner import RoidmiCleaner # noqa: F401 15 | from .ruuvitag import RuuviTag, RuuviTagPro2in1, RuuviTagPro3in1 # noqa: F401 16 | from .thermostat_ensto import EnstoThermostat # noqa: F401 17 | from .voltage_bm2 import VoltageTesterBM2 # noqa: F401 18 | from .vson_air_wp6003 import VsonWP6003 # noqa: F401 19 | from .xiaomi_ht import XiaomiHumidityTemperatureV1 # noqa: F401 20 | from .xiaomi_lywsd03 import XiaomiHumidityTemperatureLYWSD # noqa: F401 21 | from .xiaomi_lywsd03_atc import XiaomiHumidityTemperatureLYWSDATC # noqa: F401 22 | -------------------------------------------------------------------------------- /ble2mqtt/devices/atom_fast.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import struct 3 | import uuid 4 | from dataclasses import dataclass 5 | 6 | from .base import (SENSOR_DOMAIN, ConnectionMode, Sensor, 7 | SubscribeAndSetDataMixin) 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | MAIN_DATA = uuid.UUID('70BC767E-7A1A-4304-81ED-14B9AF54F7BD') 12 | 13 | 14 | @dataclass 15 | class SensorState: 16 | battery: int 17 | dose: float 18 | dose_rate: float 19 | temperature: int 20 | 21 | @classmethod 22 | def from_data(cls, sensor_data): 23 | flags, dose, dose_rate, pulses, battery, temp = \ 24 | struct.unpack('= self.SEND_DATA_PERIOD: 83 | await self._notify_state(publish_topic) 84 | timer = 0 85 | timer += self.ACTIVE_SLEEP_INTERVAL 86 | await aio.sleep(self.ACTIVE_SLEEP_INTERVAL) 87 | 88 | async def handle_messages(self, publish_topic, *args, **kwargs): 89 | while True: 90 | try: 91 | if not self.client.is_connected: 92 | raise ConnectionError() 93 | message = await aio.wait_for( 94 | self.message_queue.get(), 95 | timeout=60, 96 | ) 97 | except aio.TimeoutError: 98 | await aio.sleep(1) 99 | continue 100 | value = message['value'] 101 | entity_topic, action_postfix = self.get_entity_subtopic_from_topic( 102 | message['topic'], 103 | ) 104 | if entity_topic == self._get_topic_for_entity( 105 | self.get_entity_by_name(LIGHT_DOMAIN, LIGHT_ENTITY), 106 | skip_unique_id=True, 107 | ): 108 | _LOGGER.info(f'[{self}] set light {value}') 109 | 110 | if value.get('brightness'): 111 | self._brightness = value['brightness'] 112 | await self.write_brightness(self._brightness) 113 | 114 | target_color = None 115 | if value.get('color'): 116 | color = value['color'] 117 | try: 118 | target_color = ( 119 | color['r'], 120 | color['g'], 121 | color['b'], 122 | ) 123 | except ValueError: 124 | return 125 | if target_color != (0, 0, 0): 126 | self._color = target_color 127 | 128 | if value.get('state'): 129 | state = self.transform_value(value['state']) 130 | if state == 'ON' and target_color is None: 131 | target_color = self._color 132 | if state == 'OFF': 133 | target_color = (0, 0, 0) 134 | if target_color is not None: 135 | await self.write_color(*target_color) 136 | await self._notify_state(publish_topic) 137 | 138 | def handle_color(self, value): 139 | self._real_color = value 140 | if value != (0, 0, 0): 141 | self._color = value 142 | 143 | def handle_brightness(self, value): 144 | self._brightness = value 145 | -------------------------------------------------------------------------------- /ble2mqtt/devices/cooker_redmond.py: -------------------------------------------------------------------------------- 1 | import asyncio as aio 2 | import logging 3 | import uuid 4 | from contextlib import asynccontextmanager 5 | 6 | from ..compat import get_loop_param 7 | from ..protocols.redmond import (COOKER_PREDEFINED_PROGRAMS, CookerRunState, 8 | CookerState, RedmondCookerProtocol, 9 | RedmondError) 10 | from .base import (SELECT_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN, ConnectionMode, 11 | Device) 12 | from .uuids import DEVICE_NAME 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | UUID_NORDIC_TX = uuid.UUID("6e400002-b5a3-f393-e0a9-e50e24dcca9e") 17 | UUID_NORDIC_RX = uuid.UUID("6e400003-b5a3-f393-e0a9-e50e24dcca9e") 18 | 19 | COOK_ENTITY = 'cook' 20 | LOCK_ENTITY = 'lock' 21 | SOUND_ENTITY = 'sound' 22 | PREDEFINED_PROGRAM_ENTITY = 'predefined_program' 23 | TEMPERATURE_ENTITY = 'temperature' 24 | MODE_ENTITY = 'mode' 25 | 26 | 27 | def option_to_const(option): 28 | return option.replace(' ', '_').lower() 29 | 30 | 31 | def const_to_option(const): 32 | return const.replace('_', ' ').title() 33 | 34 | 35 | class RedmondCooker(RedmondCookerProtocol, Device): 36 | MAC_TYPE = 'random' 37 | NAME = 'redmond_rmc_m200' 38 | TX_CHAR = UUID_NORDIC_TX 39 | RX_CHAR = UUID_NORDIC_RX 40 | ACTIVE_SLEEP_INTERVAL = 1 41 | RECONNECTION_SLEEP_INTERVAL = 30 42 | MANUFACTURER = 'Redmond' 43 | ACTIVE_CONNECTION_MODE = ConnectionMode.ACTIVE_KEEP_CONNECTION 44 | 45 | SEND_DATA_PERIOD = 5 # seconds when boiling 46 | STANDBY_SEND_DATA_PERIOD_MULTIPLIER = 12 # 12 * 5 seconds in standby mode 47 | 48 | def __init__(self, mac, key='ffffffffffffffff', default_program='express', 49 | *args, **kwargs): 50 | super().__init__(mac, *args, **kwargs) 51 | assert isinstance(key, str) and len(key) == 16 52 | self._default_program = default_program 53 | self._key = bytes.fromhex(key) 54 | self._state: CookerState = None 55 | 56 | self._send_data_period_multiplier = \ 57 | self.STANDBY_SEND_DATA_PERIOD_MULTIPLIER 58 | self.initial_status_sent = False 59 | 60 | @property 61 | def entities(self): 62 | return { 63 | SWITCH_DOMAIN: [ 64 | { 65 | 'name': COOK_ENTITY, 66 | 'topic': COOK_ENTITY, 67 | 'icon': 'pot-steam', 68 | }, 69 | { 70 | 'name': LOCK_ENTITY, 71 | 'topic': LOCK_ENTITY, 72 | 'icon': 'lock', 73 | }, 74 | { 75 | 'name': SOUND_ENTITY, 76 | 'topic': SOUND_ENTITY, 77 | 'icon': 'music-note', 78 | }, 79 | ], 80 | SENSOR_DOMAIN: [ 81 | { 82 | 'name': TEMPERATURE_ENTITY, 83 | 'device_class': 'temperature', 84 | 'unit_of_measurement': '\u00b0C', 85 | }, 86 | { 87 | 'name': MODE_ENTITY, 88 | }, 89 | ], 90 | SELECT_DOMAIN: [ 91 | { 92 | 'name': PREDEFINED_PROGRAM_ENTITY, 93 | 'topic': PREDEFINED_PROGRAM_ENTITY, 94 | 'options': [ 95 | const_to_option(x) 96 | for x in COOKER_PREDEFINED_PROGRAMS.keys() 97 | ], 98 | }, 99 | ], 100 | } 101 | 102 | def get_values_by_entities(self): 103 | return { 104 | TEMPERATURE_ENTITY: self._state.target_temperature, 105 | MODE_ENTITY: self._state.state.name.title().replace('_', ' '), 106 | PREDEFINED_PROGRAM_ENTITY: const_to_option( 107 | { 108 | (state.program, state.subprogram): k 109 | for k, state in COOKER_PREDEFINED_PROGRAMS.items() 110 | }.get( 111 | (self._state.program, self._state.subprogram), 112 | '', 113 | ), 114 | ), 115 | } 116 | 117 | async def update_device_state(self): 118 | state = await self.get_mode() 119 | if state: 120 | self._state = state 121 | self.update_multiplier() 122 | self.initial_status_sent = False 123 | 124 | async def get_device_data(self): 125 | await self.protocol_start() 126 | await self.login(self._key) 127 | model = await self._read_with_timeout(DEVICE_NAME) 128 | if isinstance(model, (bytes, bytearray)): 129 | self._model = model.decode() 130 | else: 131 | # macos can't access characteristic 132 | self._model = 'RMC' 133 | version = await self.get_version() 134 | if version: 135 | self._version = f'{version[0]}.{version[1]}' 136 | await self.update_device_state() 137 | # await self.set_time() 138 | # await self._update_statistics() 139 | 140 | def update_multiplier(self, state: CookerState = None): 141 | if state is None: 142 | state = self._state 143 | self._send_data_period_multiplier = ( 144 | 1 145 | if state.state in [ 146 | CookerRunState.HEAT, 147 | CookerRunState.SETUP_PROGRAM, 148 | CookerRunState.WARM_UP, 149 | CookerRunState.COOKING, 150 | ] 151 | else self.STANDBY_SEND_DATA_PERIOD_MULTIPLIER 152 | ) 153 | 154 | async def notify_run_state(self, new_state: CookerState, publish_topic): 155 | if not self.initial_status_sent or \ 156 | new_state.state != self._state.state: 157 | state_to_str = { 158 | True: 'ON', 159 | False: 'OFF', 160 | } 161 | mode = state_to_str[new_state.state not in ( 162 | CookerRunState.OFF, 163 | CookerRunState.SETUP_PROGRAM, 164 | )] 165 | await aio.gather( 166 | publish_topic(topic=self._get_topic(COOK_ENTITY), value=mode), 167 | self._notify_state(publish_topic), 168 | ) 169 | self.initial_status_sent = True 170 | if self._state != new_state: 171 | self._state = new_state 172 | await self._notify_state(publish_topic) 173 | else: 174 | self._state = new_state 175 | else: 176 | self._state = new_state 177 | self.update_multiplier() 178 | 179 | async def handle(self, publish_topic, send_config, *args, **kwargs): 180 | counter = 0 181 | while True: 182 | await self.update_device_data(send_config) 183 | # if boiling notify every 5 seconds, 60 sec otherwise 184 | new_state = await self.get_mode() 185 | await self.notify_run_state(new_state, publish_topic) 186 | counter += 1 187 | 188 | if counter > ( 189 | self.SEND_DATA_PERIOD * self._send_data_period_multiplier 190 | ): 191 | # await self._update_statistics() 192 | await self._notify_state(publish_topic) 193 | counter = 0 194 | await aio.sleep(self.ACTIVE_SLEEP_INTERVAL) 195 | 196 | async def switch_running_mode(self, value): 197 | if value == 'ON': 198 | # switch to SETUP_PROGRAM mode if it is off 199 | if self._state.state == CookerRunState.OFF: 200 | try: 201 | await self.set_predefined_program(self._default_program) 202 | except KeyError: 203 | # if incorrect program passed, use initial mode 204 | await self.set_mode(self._state) 205 | await self.run() 206 | next_state = CookerRunState.COOKING # any of heating stages 207 | else: 208 | await self.stop() 209 | next_state = CookerRunState.OFF 210 | self.update_multiplier(CookerState(state=next_state)) 211 | 212 | async def handle_messages(self, publish_topic, *args, **kwargs): 213 | def is_entity_topic(entity, topic): 214 | return topic == self._get_topic_for_entity( 215 | entity, 216 | skip_unique_id=True, 217 | ) 218 | 219 | @asynccontextmanager 220 | async def process_entity_change(entity, value): 221 | value = self.transform_value(value) 222 | _LOGGER.info( 223 | f'[{self}] switch cooker {entity["name"]} value={value}', 224 | ) 225 | for _ in range(10): 226 | try: 227 | yield 228 | # update state to real values 229 | await self.update_device_state() 230 | await aio.gather( 231 | publish_topic( 232 | topic=self._get_topic_for_entity(entity), 233 | value=self.transform_value(value), 234 | ), 235 | self._notify_state(publish_topic), 236 | **get_loop_param(self._loop), 237 | ) 238 | break 239 | except ConnectionError as e: 240 | _LOGGER.exception(str(e)) 241 | await aio.sleep(5) 242 | 243 | while True: 244 | try: 245 | if not self.client.is_connected: 246 | raise ConnectionError() 247 | message = await aio.wait_for( 248 | self.message_queue.get(), 249 | timeout=60, 250 | ) 251 | except aio.TimeoutError: 252 | await aio.sleep(1) 253 | continue 254 | value = message['value'] 255 | 256 | entity_topic, action_postfix = self.get_entity_subtopic_from_topic( 257 | message['topic'], 258 | ) 259 | entity = self.get_entity_by_name(SWITCH_DOMAIN, COOK_ENTITY) 260 | if is_entity_topic(entity, entity_topic): 261 | async with process_entity_change(entity, value): 262 | try: 263 | await self.switch_running_mode(value) 264 | except RedmondError: 265 | _LOGGER.exception( 266 | f'[{self}] Problem with switching cooker', 267 | ) 268 | continue 269 | 270 | entity = self.get_entity_by_name(SWITCH_DOMAIN, SOUND_ENTITY) 271 | if is_entity_topic(entity, entity_topic): 272 | async with process_entity_change(entity, value): 273 | await self.set_sound(value == 'ON') 274 | continue 275 | 276 | entity = self.get_entity_by_name(SWITCH_DOMAIN, LOCK_ENTITY) 277 | if is_entity_topic(entity, entity_topic): 278 | async with process_entity_change(entity, value): 279 | await self.set_lock(value == 'ON') 280 | continue 281 | 282 | entity = self.get_entity_by_name( 283 | SELECT_DOMAIN, 284 | PREDEFINED_PROGRAM_ENTITY, 285 | ) 286 | if is_entity_topic(entity, entity_topic): 287 | try: 288 | value = option_to_const(value) 289 | except KeyError: 290 | _LOGGER.error(f'{self} program "{value}" does not exist') 291 | continue 292 | _LOGGER.info(f'set predefined program {value}') 293 | 294 | while True: 295 | try: 296 | await self.set_predefined_program(value) 297 | # update state to real values 298 | await self.update_device_state() 299 | await aio.gather( 300 | publish_topic( 301 | topic=self._get_topic_for_entity(entity), 302 | value=const_to_option( 303 | self.transform_value(value), 304 | ), 305 | ), 306 | self._notify_state(publish_topic), 307 | **get_loop_param(self._loop), 308 | ) 309 | break 310 | except ConnectionError as e: 311 | _LOGGER.exception(str(e)) 312 | await aio.sleep(5) 313 | continue 314 | -------------------------------------------------------------------------------- /ble2mqtt/devices/cover_am43.py: -------------------------------------------------------------------------------- 1 | import asyncio as aio 2 | import logging 3 | import typing as ty 4 | import uuid 5 | from dataclasses import dataclass 6 | from enum import Enum 7 | 8 | from ..protocols.am43 import AM43Protocol 9 | from .base import SENSOR_DOMAIN, BaseCover, ConnectionMode, CoverRunState 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | BATTERY_ENTITY = 'battery' 14 | ILLUMINANCE_ENTITY = 'illuminance' 15 | 16 | BLINDS_CONTROL = uuid.UUID("0000fe51-0000-1000-8000-00805f9b34fb") 17 | 18 | 19 | class MovementType(Enum): 20 | STOP = 0 21 | POSITION = 1 22 | 23 | 24 | @dataclass 25 | class AM43State: 26 | battery: ty.Optional[int] = None 27 | position: int = 0 28 | illuminance: int = 0 29 | run_state: CoverRunState = CoverRunState.CLOSED 30 | target_position: ty.Optional[int] = None 31 | 32 | 33 | class AM43Cover(AM43Protocol, BaseCover): 34 | NAME = 'am43' 35 | MANUFACTURER = 'Blind' 36 | DATA_CHAR = BLINDS_CONTROL 37 | ACTIVE_SLEEP_INTERVAL = 1 38 | SEND_DATA_PERIOD = 5 39 | STANDBY_SEND_DATA_PERIOD_MULTIPLIER = 12 * 5 # 5 minutes 40 | ACTIVE_CONNECTION_MODE = ConnectionMode.ACTIVE_KEEP_CONNECTION 41 | 42 | # HA notation. We convert value on setting and receiving data 43 | CLOSED_POSITION = 0 44 | OPEN_POSITION = 100 45 | 46 | @property 47 | def entities(self): 48 | return { 49 | **super().entities, 50 | SENSOR_DOMAIN: [ 51 | { 52 | 'name': BATTERY_ENTITY, 53 | 'device_class': 'battery', 54 | 'unit_of_measurement': '%', 55 | 'entity_category': 'diagnostic', 56 | }, 57 | { 58 | 'name': ILLUMINANCE_ENTITY, 59 | 'device_class': 'illuminance', 60 | 'unit_of_measurement': 'lx', 61 | }, 62 | ], 63 | } 64 | 65 | def __init__(self, *args, **kwargs): 66 | super().__init__(*args, **kwargs) 67 | self._model = 'AM43' 68 | self._state = AM43State() 69 | self.initial_status_sent = False 70 | 71 | def get_values_by_entities(self): 72 | return { 73 | BATTERY_ENTITY: self._state.battery, 74 | ILLUMINANCE_ENTITY: self._state.illuminance, 75 | self.COVER_ENTITY: { 76 | 'state': self._state.run_state.value, 77 | 'position': self._state.position, 78 | }, 79 | } 80 | 81 | async def get_device_data(self): 82 | await super().get_device_data() 83 | await self.client.start_notify( 84 | self.DATA_CHAR, 85 | self.notification_callback, 86 | ) 87 | await self._update_full_state() 88 | 89 | async def _update_running_state(self): 90 | await self._get_position() 91 | 92 | async def _update_full_state(self): 93 | await super()._update_full_state() 94 | 95 | async def handle(self, publish_topic, send_config, *args, **kwargs): 96 | # request every SEND_DATA_PERIOD if running and 97 | # SEND_DATA_PERIOD * STANDBY_SEND_DATA_PERIOD_MULTIPLIER if in 98 | # standby mode 99 | 100 | timer = 0 101 | while True: 102 | await self.update_device_data(send_config) 103 | # if running notify every 5 seconds, 60 sec otherwise 104 | is_running = self._state.run_state in [ 105 | CoverRunState.OPENING, 106 | CoverRunState.CLOSING, 107 | ] 108 | multiplier = ( 109 | 1 if is_running else self.STANDBY_SEND_DATA_PERIOD_MULTIPLIER 110 | ) 111 | 112 | timer += self.ACTIVE_SLEEP_INTERVAL 113 | if not self.initial_status_sent or \ 114 | timer >= self.SEND_DATA_PERIOD * multiplier: 115 | if is_running: 116 | _LOGGER.debug(f'[{self}] check for position') 117 | await self._update_running_state() 118 | if self._state.position == self.CLOSED_POSITION: 119 | _LOGGER.info( 120 | f'[{self}] Minimum position reached. Set to CLOSED', 121 | ) 122 | self._state.run_state = CoverRunState.CLOSED 123 | elif self._state.position == self.OPEN_POSITION: 124 | _LOGGER.info( 125 | f'[{self}] Maximum position reached. Set to OPEN', 126 | ) 127 | self._state.run_state = CoverRunState.OPEN 128 | else: 129 | _LOGGER.debug(f'[{self}] check for full state') 130 | await self._update_full_state() 131 | await self._notify_state(publish_topic) 132 | timer = 0 133 | await aio.sleep(self.ACTIVE_SLEEP_INTERVAL) 134 | 135 | def handle_battery(self, value): 136 | self._state.battery = value 137 | 138 | def handle_position(self, value): 139 | self._state.position = value 140 | 141 | def handle_illuminance(self, value): 142 | self._state.illuminance = value 143 | -------------------------------------------------------------------------------- /ble2mqtt/devices/cover_soma.py: -------------------------------------------------------------------------------- 1 | import asyncio as aio 2 | import logging 3 | import typing as ty 4 | import uuid 5 | from dataclasses import dataclass 6 | from enum import Enum 7 | 8 | from ..protocols.soma import MotorCommandCodes, SomaProtocol 9 | from .base import SENSOR_DOMAIN, BaseCover, ConnectionMode, CoverRunState 10 | from .uuids import BATTERY, SOFTWARE_VERSION 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | BATTERY_ENTITY = 'battery' 15 | ILLUMINANCE_ENTITY = 'illuminance' 16 | 17 | POSITION_UUID = uuid.UUID('00001525-b87f-490c-92cb-11ba5ea5167c') 18 | MOVE_PERCENT_UUID = uuid.UUID('00001526-b87f-490c-92cb-11ba5ea5167c') 19 | MOTOR_UUID = uuid.UUID('00001530-b87f-490c-92cb-11ba5ea5167c') 20 | NOTIFY_UUID = uuid.UUID('00001531-b87f-490c-92cb-11ba5ea5167c') 21 | GROUP_UUID = uuid.UUID('00001893-b87f-490c-92cb-11ba5ea5167c') 22 | NAME_UUID = uuid.UUID('00001892-b87f-490c-92cb-11ba5ea5167c') 23 | CALIBRATE_UUID = uuid.UUID('00001529-b87f-490c-92cb-11ba5ea5167c') 24 | STATE_UUID = uuid.UUID('00001894-b87f-490c-92cb-11ba5ea5167c') 25 | CONFIG_UUID = uuid.UUID('00001896-b87f-490c-92cb-11ba5ea5167c') 26 | 27 | 28 | class MovementType(Enum): 29 | STOP = 0 30 | POSITION = 1 31 | 32 | 33 | @dataclass 34 | class SomaState: 35 | battery: ty.Optional[int] = None 36 | position: int = 0 37 | illuminance: int = 0 38 | motor_speed: int = 0 39 | run_state: CoverRunState = CoverRunState.CLOSED 40 | target_position: ty.Optional[int] = None 41 | 42 | 43 | class SomaCover(SomaProtocol, BaseCover): 44 | NAME = 'soma_shades' 45 | MANUFACTURER = 'Soma' 46 | 47 | NAME_CHAR = NAME_UUID 48 | POSITION_CHAR = POSITION_UUID 49 | MOTOR_CHAR = MOTOR_UUID 50 | SET_POSITION_CHAR = MOVE_PERCENT_UUID 51 | CHARGING_CHAR = STATE_UUID 52 | BATTERY_CHAR = BATTERY 53 | CONFIG_CHAR = CONFIG_UUID 54 | 55 | ACTIVE_SLEEP_INTERVAL = 1 56 | SEND_DATA_PERIOD = 5 57 | STANDBY_SEND_DATA_PERIOD_MULTIPLIER = 12 * 5 # 5 minutes 58 | ACTIVE_CONNECTION_MODE = ConnectionMode.ACTIVE_KEEP_CONNECTION 59 | 60 | # HA notation. We convert value on setting and receiving data 61 | CLOSED_POSITION = 0 62 | OPEN_POSITION = 100 63 | 64 | @property 65 | def entities(self): 66 | return { 67 | **super().entities, 68 | SENSOR_DOMAIN: [ 69 | { 70 | 'name': BATTERY_ENTITY, 71 | 'device_class': 'battery', 72 | 'unit_of_measurement': '%', 73 | 'entity_category': 'diagnostic', 74 | }, 75 | { 76 | 'name': ILLUMINANCE_ENTITY, 77 | 'device_class': 'illuminance', 78 | 'unit_of_measurement': 'lx', 79 | }, 80 | ], 81 | } 82 | 83 | def __init__(self, *args, **kwargs): 84 | super().__init__(*args, **kwargs) 85 | # self._model = 'AM43' 86 | self._state = SomaState() 87 | self.initial_status_sent = False 88 | 89 | def get_values_by_entities(self): 90 | return { 91 | BATTERY_ENTITY: self._state.battery, 92 | ILLUMINANCE_ENTITY: self._state.illuminance, 93 | self.COVER_ENTITY: { 94 | 'state': self._state.run_state.value, 95 | 'position': self._state.position, 96 | }, 97 | } 98 | 99 | async def get_device_data(self): 100 | await super().get_device_data() 101 | name = await self._read_with_timeout(self.NAME_CHAR) 102 | if isinstance(name, (bytes, bytearray)): 103 | self._model = name.decode().strip(' \0') 104 | version = await self.client.read_gatt_char(SOFTWARE_VERSION) 105 | if version: 106 | self._version = version.decode() 107 | _LOGGER.debug(f'{self} name: {name}, version: {version}') 108 | 109 | cb = self.notification_callback 110 | await self.client.start_notify(self.CONFIG_CHAR, cb) 111 | await self.client.start_notify(self.POSITION_CHAR, cb) 112 | await self.client.start_notify(self.CHARGING_CHAR, cb) 113 | await self._update_full_state() 114 | 115 | def _handle_position(self, value): 116 | self._state.position = value 117 | 118 | def _handle_charging(self, *, charging_level, panel_level): 119 | self._state.illuminance = charging_level 120 | 121 | def _handle_motor_run_state(self, run_state: MotorCommandCodes): 122 | # Ignore run state. 123 | # We calculate run state on position and target position 124 | pass 125 | 126 | async def _update_full_state(self): 127 | await self._update_running_state() 128 | self._state.battery = await self._get_battery() 129 | self._state.target_position = await self._get_target_position() 130 | self._state.illuminance = \ 131 | (await self._get_light_and_panel())['charging_level'] 132 | self._state.run_state = self._get_run_state() 133 | 134 | async def _update_running_state(self): 135 | self._state.position = await self._get_position() 136 | self._state.motor_speed = await self._get_motor_speed() 137 | 138 | def _get_run_state(self) -> CoverRunState: 139 | if self._state.target_position == self._state.position == \ 140 | self.OPEN_POSITION: 141 | return CoverRunState.OPEN 142 | elif self._state.target_position == self._state.position == \ 143 | self.CLOSED_POSITION: 144 | return CoverRunState.CLOSED 145 | elif self._state.target_position < self._state.position: 146 | return CoverRunState.CLOSING 147 | elif self._state.target_position > self._state.position: 148 | return CoverRunState.OPENING 149 | return CoverRunState.STOPPED 150 | 151 | async def handle(self, publish_topic, send_config, *args, **kwargs): 152 | # request every SEND_DATA_PERIOD if running and 153 | # SEND_DATA_PERIOD * STANDBY_SEND_DATA_PERIOD_MULTIPLIER if in 154 | # standby mode 155 | 156 | timer = 0 157 | while True: 158 | await self.update_device_data(send_config) 159 | # if running notify every 5 seconds, 60 sec otherwise 160 | is_running = self._state.run_state in [ 161 | CoverRunState.OPENING, 162 | CoverRunState.CLOSING, 163 | ] 164 | multiplier = ( 165 | 1 if is_running else self.STANDBY_SEND_DATA_PERIOD_MULTIPLIER 166 | ) 167 | 168 | timer += self.ACTIVE_SLEEP_INTERVAL 169 | if not self.initial_status_sent or \ 170 | timer >= self.SEND_DATA_PERIOD * multiplier: 171 | if is_running: 172 | _LOGGER.debug(f'[{self}] check for position') 173 | await self._update_running_state() 174 | if self._state.position == self._state.target_position: 175 | if self._state.position == self.CLOSED_POSITION: 176 | _LOGGER.info( 177 | f'[{self}] Minimum position reached. ' 178 | f'Set to CLOSED', 179 | ) 180 | self._state.run_state = CoverRunState.CLOSED 181 | elif self._state.position == self.OPEN_POSITION: 182 | _LOGGER.info( 183 | f'[{self}] Maximum position reached. ' 184 | f'Set to OPEN', 185 | ) 186 | self._state.run_state = CoverRunState.OPEN 187 | else: 188 | _LOGGER.debug(f'[{self}] check for full state') 189 | await self._update_full_state() 190 | await self._notify_state(publish_topic) 191 | timer = 0 192 | await aio.sleep(self.ACTIVE_SLEEP_INTERVAL) 193 | -------------------------------------------------------------------------------- /ble2mqtt/devices/flower_mclh09.py: -------------------------------------------------------------------------------- 1 | import asyncio as aio 2 | import logging 3 | import struct 4 | import uuid 5 | from dataclasses import dataclass 6 | 7 | from .base import SENSOR_DOMAIN, ConnectionMode, Sensor 8 | from .uuids import BATTERY 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | FLOWER_SENSOR_CHAR = uuid.UUID('55482920-eacb-11e3-918a-0002a5d5c51b') 13 | 14 | TEMPERATURE_VALUES = [68.8, 49.8, 24.3, 6.4, 1.0, -5.5, -20.5, -41.0] 15 | TEMPERATURE_READINGS = [1035, 909, 668, 424, 368, 273, 159, 0] 16 | MOISTURE_VALUES = [60.0, 58.0, 54.0, 22.0, 2.0, 0.0] 17 | MOISTURE_READINGS = [1254, 1249, 1202, 1104, 944, 900] 18 | LIGHT_VALUES = [ 19 | 175300.0, 45400.0, 32100.0, 20300.0, 14760.0, 7600.0, 1200.0, 444.0, 20 | 29.0, 17.0, 0.0, 21 | ] 22 | LIGHT_READINGS = [911, 764, 741, 706, 645, 545, 196, 117, 24, 17, 0] 23 | 24 | 25 | def _interpolate(raw_value, values, raw_values): 26 | index = 0 27 | if raw_value > raw_values[0]: 28 | index = 0 29 | elif raw_value < raw_values[-2]: 30 | index = len(raw_values) - 2 31 | else: 32 | while raw_value < raw_values[index + 1]: 33 | index += 1 34 | 35 | delta_value = values[index] - values[index + 1] 36 | delta_raw = raw_values[index] - raw_values[index + 1] 37 | return ( 38 | (raw_value - raw_values[index + 1]) * delta_value / delta_raw + 39 | values[index + 1] 40 | ) 41 | 42 | 43 | def calculate_temperature(raw_value): 44 | return _interpolate(raw_value, TEMPERATURE_VALUES, TEMPERATURE_READINGS) 45 | 46 | 47 | def calculate_moisture(raw_value): 48 | moisture = _interpolate(raw_value, MOISTURE_VALUES, MOISTURE_READINGS) 49 | 50 | if moisture > 100.0: 51 | moisture = 100.0 52 | if moisture < 0.0: 53 | moisture = 0.0 54 | 55 | return moisture 56 | 57 | 58 | def calculate_illuminance(raw_value): 59 | return _interpolate(raw_value, LIGHT_VALUES, LIGHT_READINGS) 60 | 61 | 62 | @dataclass 63 | class SensorState: 64 | temperature: float 65 | moisture: float 66 | illuminance: int 67 | battery: int = 0 68 | 69 | @classmethod 70 | def from_data(cls, data: bytes, battery: bytes): 71 | temp_raw, moisture_raw, illuminance_raw = struct.unpack(' None: 90 | super().__init__(mac, *args, **kwargs) 91 | self.RECONNECTION_SLEEP_INTERVAL = int( 92 | kwargs.get('interval', self.DEFAULT_RECONNECTION_SLEEP_INTERVAL) 93 | ) 94 | 95 | @property 96 | def entities(self): 97 | return { 98 | SENSOR_DOMAIN: [ 99 | { 100 | 'name': 'temperature', 101 | 'device_class': 'temperature', 102 | 'unit_of_measurement': '\u00b0C', 103 | }, 104 | { 105 | 'name': 'moisture', 106 | 'device_class': 'moisture', 107 | 'unit_of_measurement': '%', 108 | }, 109 | { 110 | 'name': 'illuminance', 111 | 'device_class': 'illuminance', 112 | 'unit_of_measurement': 'lx', 113 | }, 114 | { 115 | 'name': 'battery', 116 | 'device_class': 'battery', 117 | 'unit_of_measurement': '%', 118 | 'entity_category': 'diagnostic', 119 | }, 120 | ], 121 | } 122 | 123 | async def read_state(self): 124 | battery = await self._read_with_timeout(self.BATTERY_CHAR) 125 | data = await self._read_with_timeout(self.DATA_CHAR) 126 | for _ in range(5): 127 | try: 128 | self._state = SensorState.from_data(data, battery) 129 | except ValueError as e: 130 | _LOGGER.warning(f'{self} {repr(e)}') 131 | await aio.sleep(1) 132 | else: 133 | break 134 | 135 | async def do_active_loop(self, publish_topic): 136 | try: 137 | await aio.wait_for(self.read_state(), 5) 138 | await self._notify_state(publish_topic) 139 | except (aio.CancelledError, KeyboardInterrupt): 140 | raise 141 | except Exception: 142 | _LOGGER.exception(f'{self} problem with reading values') 143 | -------------------------------------------------------------------------------- /ble2mqtt/devices/flower_miflora.py: -------------------------------------------------------------------------------- 1 | import asyncio as aio 2 | import logging 3 | import struct 4 | import typing as ty 5 | import uuid 6 | from dataclasses import dataclass 7 | 8 | from .base import SENSOR_DOMAIN, ConnectionMode, Sensor 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | DEVICE_MODE_UUID = uuid.UUID('00001a00-0000-1000-8000-00805f9b34fb') 13 | DATA_UUID = uuid.UUID('00001a01-0000-1000-8000-00805f9b34fb') 14 | FIRMWARE_UUID = uuid.UUID('00001a02-0000-1000-8000-00805f9b34fb') 15 | 16 | LIVE_MODE_CMD = bytes([0xA0, 0x1F]) 17 | 18 | 19 | @dataclass 20 | class SensorState: 21 | temperature: float 22 | moisture: int 23 | illuminance: ty.Optional[int] 24 | conductivity: int 25 | battery: int = 0 26 | 27 | @classmethod 28 | def from_data(cls, data: bytes, battery: int): 29 | if len(data) == 24: # is ropot 30 | light = None 31 | temp, moist, conductivity = struct.unpack( 32 | " None: 57 | super().__init__(mac, *args, **kwargs) 58 | self.RECONNECTION_SLEEP_INTERVAL = int( 59 | kwargs.get('interval', self.DEFAULT_RECONNECTION_SLEEP_INTERVAL) 60 | ) 61 | 62 | @property 63 | def entities(self): 64 | return { 65 | SENSOR_DOMAIN: [ 66 | { 67 | 'name': 'temperature', 68 | 'device_class': 'temperature', 69 | 'unit_of_measurement': '\u00b0C', 70 | }, 71 | { 72 | 'name': 'moisture', 73 | 'device_class': 'moisture', 74 | 'unit_of_measurement': '%', 75 | }, 76 | { 77 | 'name': 'illuminance', 78 | 'device_class': 'illuminance', 79 | 'unit_of_measurement': 'lx', 80 | }, 81 | { 82 | 'name': 'conductivity', 83 | 'unit_of_measurement': 'µS/cm', 84 | }, 85 | { 86 | 'name': 'battery', 87 | 'device_class': 'battery', 88 | 'unit_of_measurement': '%', 89 | 'entity_category': 'diagnostic', 90 | }, 91 | ], 92 | } 93 | 94 | async def read_state(self): 95 | fw_and_bat = await self._read_with_timeout(FIRMWARE_UUID) 96 | battery = fw_and_bat[0] 97 | data = await self._read_with_timeout(DATA_UUID) 98 | for _ in range(5): 99 | try: 100 | self._state = SensorState.from_data(data, battery) 101 | except ValueError as e: 102 | _LOGGER.warning(f'{self} {repr(e)}') 103 | await aio.sleep(1) 104 | else: 105 | break 106 | 107 | async def get_device_data(self): 108 | self._model = self.MODEL 109 | await self.client.write_gatt_char( 110 | DEVICE_MODE_UUID, 111 | LIVE_MODE_CMD, 112 | response=False, 113 | ) 114 | fw_and_bat = await self._read_with_timeout(FIRMWARE_UUID) 115 | self._version = fw_and_bat[2:].decode('ascii').strip('\0') 116 | 117 | async def do_active_loop(self, publish_topic): 118 | try: 119 | await aio.wait_for(self.read_state(), 5) 120 | await self._notify_state(publish_topic) 121 | except (aio.CancelledError, KeyboardInterrupt): 122 | raise 123 | except Exception: 124 | _LOGGER.exception(f'{self} problem with reading values') 125 | -------------------------------------------------------------------------------- /ble2mqtt/devices/govee.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from bleak.backends.device import BLEDevice 4 | 5 | from ..devices.base import HumidityTemperatureSensor 6 | from ..protocols.govee import GoveeDecoder 7 | from ..utils import format_binary 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | def valid_data_length(raw_data): 12 | valid_lengths = [6, 7] 13 | return len(raw_data) in valid_lengths 14 | 15 | 16 | class GoveeTemperature(HumidityTemperatureSensor): 17 | NAME = 'govee_ht' 18 | MANUFACTURER = 'Govee' 19 | SUPPORT_PASSIVE = True 20 | SUPPORT_ACTIVE = False 21 | 22 | def handle_advert(self, scanned_device: BLEDevice, adv_data): 23 | raw_data = adv_data.manufacturer_data.get(0xec88) 24 | if not raw_data: 25 | _LOGGER.debug( 26 | 'Temperature data not found; got ' 27 | f'{repr(adv_data.manufacturer_data)}', 28 | ) 29 | return 30 | 31 | if not valid_data_length(raw_data): 32 | _LOGGER.debug( 33 | 'Unexpected raw data length ' 34 | f'{len(raw_data)} ({repr(raw_data)})', 35 | ) 36 | 37 | decoder = GoveeDecoder(bytes(raw_data)) 38 | self._state = self.SENSOR_CLASS( 39 | temperature=decoder.temperature_celsius, 40 | humidity=decoder.humidity_percentage, 41 | battery=decoder.battery_percentage, 42 | ) 43 | 44 | _LOGGER.debug( 45 | f'Advert received for {self}, {format_binary(raw_data)}, ' 46 | f'current state: {self._state}', 47 | ) 48 | -------------------------------------------------------------------------------- /ble2mqtt/devices/kettle_redmond.py: -------------------------------------------------------------------------------- 1 | import asyncio as aio 2 | import logging 3 | import uuid 4 | 5 | from ..compat import get_loop_param 6 | from ..protocols.redmond import (ColorTarget, KettleG200Mode, KettleG200State, 7 | KettleRunState, RedmondKettle200Protocol) 8 | from .base import (LIGHT_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN, ConnectionMode, 9 | Device) 10 | from .uuids import DEVICE_NAME 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | UUID_NORDIC_TX = uuid.UUID("6e400002-b5a3-f393-e0a9-e50e24dcca9e") 15 | UUID_NORDIC_RX = uuid.UUID("6e400003-b5a3-f393-e0a9-e50e24dcca9e") 16 | 17 | BOIL_ENTITY = 'boil' 18 | HEAT_ENTITY = 'heat' # not implemented yet 19 | TEMPERATURE_ENTITY = 'temperature' 20 | ENERGY_ENTITY = 'energy' 21 | LIGHT_ENTITY = 'backlight' 22 | STATISTICS_ENTITY = 'statistics' 23 | 24 | 25 | class RedmondKettle(RedmondKettle200Protocol, Device): 26 | MAC_TYPE = 'random' 27 | NAME = 'redmond_rk_g200' 28 | TX_CHAR = UUID_NORDIC_TX 29 | RX_CHAR = UUID_NORDIC_RX 30 | ACTIVE_SLEEP_INTERVAL = 1 31 | RECONNECTION_SLEEP_INTERVAL = 30 32 | MANUFACTURER = 'Redmond' 33 | ACTIVE_CONNECTION_MODE = ConnectionMode.ACTIVE_KEEP_CONNECTION 34 | 35 | SEND_DATA_PERIOD = 5 # seconds when boiling 36 | STANDBY_SEND_DATA_PERIOD_MULTIPLIER = 12 # 12 * 5 seconds in standby mode 37 | 38 | def __init__(self, mac, key='ffffffffffffffff', 39 | *args, **kwargs): 40 | super().__init__(mac, *args, **kwargs) 41 | assert isinstance(key, str) and len(key) == 16 42 | self._key = bytes.fromhex(key) 43 | self._state = None 44 | self._color = (255, 255, 255) 45 | self._brightness = 255 46 | self._statistics = {} 47 | self._energy = None 48 | 49 | self._send_data_period_multiplier = \ 50 | self.STANDBY_SEND_DATA_PERIOD_MULTIPLIER 51 | self.initial_status_sent = False 52 | 53 | @property 54 | def entities(self): 55 | return { 56 | SWITCH_DOMAIN: [ 57 | { 58 | 'name': BOIL_ENTITY, 59 | 'topic': BOIL_ENTITY, 60 | 'icon': 'kettle', 61 | }, 62 | ], 63 | SENSOR_DOMAIN: [ 64 | { 65 | 'name': TEMPERATURE_ENTITY, 66 | 'device_class': 'temperature', 67 | 'unit_of_measurement': '\u00b0C', 68 | }, 69 | { 70 | 'name': ENERGY_ENTITY, 71 | 'device_class': 'energy', 72 | 'unit_of_measurement': 'kWh', 73 | 'state_class': 'total_increasing', 74 | 'entity_category': 'diagnostic', 75 | }, 76 | { 77 | 'name': STATISTICS_ENTITY, 78 | 'topic': STATISTICS_ENTITY, 79 | 'icon': 'chart-bar', 80 | 'json': True, 81 | 'main_value': 'number_of_starts', 82 | 'unit_of_measurement': ' ', 83 | 'entity_category': 'diagnostic', 84 | }, 85 | ], 86 | LIGHT_DOMAIN: [ 87 | { 88 | 'name': LIGHT_ENTITY, 89 | 'topic': LIGHT_ENTITY, 90 | }, 91 | ], 92 | } 93 | 94 | def get_values_by_entities(self): 95 | return { 96 | TEMPERATURE_ENTITY: self._state.temperature, 97 | ENERGY_ENTITY: self._energy, 98 | STATISTICS_ENTITY: self._statistics, 99 | LIGHT_ENTITY: { 100 | 'state': ( 101 | KettleRunState.ON.name 102 | if self._state.state == KettleRunState.ON and 103 | self._state.mode == KettleG200Mode.LIGHT 104 | else KettleRunState.OFF.name 105 | ), 106 | 'brightness': 255, 107 | 'color': { 108 | 'r': self._color[0], 109 | 'g': self._color[1], 110 | 'b': self._color[2], 111 | }, 112 | 'color_mode': 'rgb', 113 | }, 114 | } 115 | 116 | async def get_device_data(self): 117 | await self.protocol_start() 118 | await self.login(self._key) 119 | model = await self._read_with_timeout(DEVICE_NAME) 120 | if isinstance(model, (bytes, bytearray)): 121 | self._model = model.decode() 122 | else: 123 | # macos can't access characteristic 124 | self._model = 'G200S' 125 | version = await self.get_version() 126 | if version: 127 | self._version = f'{version[0]}.{version[1]}' 128 | state = await self.get_mode() 129 | if state: 130 | self._state = state 131 | self.update_multiplier() 132 | self.initial_status_sent = False 133 | await self.set_time() 134 | await self._update_statistics() 135 | 136 | def update_multiplier(self, state: KettleG200State = None): 137 | if state is None: 138 | state = self._state 139 | self._send_data_period_multiplier = ( 140 | 1 141 | if state.state == KettleRunState.ON and state.mode in [ 142 | KettleG200Mode.BOIL, 143 | KettleG200Mode.HEAT, 144 | ] 145 | else self.STANDBY_SEND_DATA_PERIOD_MULTIPLIER 146 | ) 147 | 148 | async def notify_run_state(self, new_state: KettleG200State, publish_topic): 149 | if not self.initial_status_sent or \ 150 | new_state.state != self._state.state or \ 151 | new_state.mode != self._state.mode: 152 | state_to_str = { 153 | True: KettleRunState.ON.name, 154 | False: KettleRunState.OFF.name, 155 | } 156 | boil_mode = state_to_str[ 157 | new_state.mode == KettleG200Mode.BOIL and 158 | new_state.state == KettleRunState.ON 159 | ] 160 | heat_mode = state_to_str[ 161 | new_state.mode == KettleG200Mode.HEAT and 162 | new_state.state == KettleRunState.ON 163 | ] 164 | topics = { 165 | BOIL_ENTITY: boil_mode, 166 | HEAT_ENTITY: heat_mode, 167 | } 168 | await aio.gather( 169 | *[ 170 | publish_topic(topic=self._get_topic(topic), value=value) 171 | for topic, value in topics.items() 172 | ], 173 | self._notify_state(publish_topic), 174 | ) 175 | self.initial_status_sent = True 176 | if self._state != new_state: 177 | self._state = new_state 178 | await self._notify_state(publish_topic) 179 | else: 180 | self._state = new_state 181 | else: 182 | self._state = new_state 183 | self.update_multiplier() 184 | 185 | async def _update_statistics(self): 186 | statistics = await self.get_statistics() 187 | self._statistics = { 188 | 'number_of_starts': statistics['starts'], 189 | 'Energy spent (kWh)': round(statistics['watts_hours']/1000, 2), 190 | 'Working time (minutes)': round(statistics['seconds_run']/60, 1), 191 | } 192 | self._energy = round(statistics['watts_hours']/1000, 2) 193 | 194 | async def handle(self, publish_topic, send_config, *args, **kwargs): 195 | counter = 0 196 | while True: 197 | await self.update_device_data(send_config) 198 | # if boiling notify every 5 seconds, 60 sec otherwise 199 | new_state = await self.get_mode() 200 | await self.notify_run_state(new_state, publish_topic) 201 | counter += 1 202 | 203 | if counter > ( 204 | self.SEND_DATA_PERIOD * self._send_data_period_multiplier 205 | ): 206 | await self._update_statistics() 207 | await self._notify_state(publish_topic) 208 | counter = 0 209 | await aio.sleep(self.ACTIVE_SLEEP_INTERVAL) 210 | 211 | async def _switch_mode(self, mode, value): 212 | if value == KettleRunState.ON.name: 213 | try: 214 | if self._state.mode != mode: 215 | await self.stop() 216 | await self.set_mode(KettleG200State(mode=mode)) 217 | except ValueError: 218 | # if the MODE is the same then it returns 219 | # en error. Treat it as normal 220 | pass 221 | await self.run() 222 | next_state = KettleRunState.ON 223 | else: 224 | await self.stop() 225 | next_state = KettleRunState.OFF 226 | self.update_multiplier(KettleG200State(state=next_state)) 227 | 228 | async def _switch_boil(self, value): 229 | await self._switch_mode(KettleG200Mode.BOIL, value) 230 | 231 | async def _switch_backlight(self, value): 232 | await self._switch_mode(KettleG200Mode.LIGHT, value) 233 | 234 | async def handle_messages(self, publish_topic, *args, **kwargs): 235 | while True: 236 | try: 237 | if not self.client.is_connected: 238 | raise ConnectionError() 239 | message = await aio.wait_for( 240 | self.message_queue.get(), 241 | timeout=60, 242 | ) 243 | except aio.TimeoutError: 244 | await aio.sleep(1) 245 | continue 246 | value = message['value'] 247 | entity_topic, action_postfix = self.get_entity_subtopic_from_topic( 248 | message['topic'], 249 | ) 250 | entity = self.get_entity_by_name(SWITCH_DOMAIN, BOIL_ENTITY) 251 | if entity_topic == self._get_topic_for_entity( 252 | entity, 253 | skip_unique_id=True, 254 | ): 255 | value = self.transform_value(value) 256 | _LOGGER.info( 257 | f'[{self}] switch kettle {BOIL_ENTITY} value={value}', 258 | ) 259 | while True: 260 | try: 261 | await self._switch_boil(value) 262 | # update state to real values 263 | await self.get_mode() 264 | await aio.gather( 265 | publish_topic( 266 | topic=self._get_topic_for_entity(entity), 267 | value=self.transform_value(value), 268 | ), 269 | self._notify_state(publish_topic), 270 | **get_loop_param(self._loop), 271 | ) 272 | break 273 | except ConnectionError as e: 274 | _LOGGER.exception(str(e)) 275 | await aio.sleep(5) 276 | continue 277 | 278 | entity = self.get_entity_by_name(LIGHT_DOMAIN, LIGHT_ENTITY) 279 | if entity_topic == self._get_topic_for_entity( 280 | entity, 281 | skip_unique_id=True, 282 | ): 283 | _LOGGER.info(f'set backlight {value}') 284 | if value.get('state'): 285 | await self._switch_backlight(value['state']) 286 | if value.get('color') or value.get('brightness'): 287 | if value.get('color'): 288 | color = value['color'] 289 | try: 290 | self._color = color['r'], color['g'], color['b'] 291 | except ValueError: 292 | return 293 | if value.get('brightness'): 294 | self._brightness = value['brightness'] 295 | await aio.gather( 296 | self.set_color( 297 | ColorTarget.LIGHT, 298 | *self._color, 299 | self._brightness, 300 | ), 301 | self._notify_state(publish_topic), 302 | **get_loop_param(self._loop), 303 | ) 304 | -------------------------------------------------------------------------------- /ble2mqtt/devices/kettle_xiaomi.py: -------------------------------------------------------------------------------- 1 | import asyncio as aio 2 | import logging 3 | import struct 4 | import time 5 | import typing as ty 6 | import uuid 7 | from dataclasses import dataclass 8 | from enum import Enum 9 | 10 | from ..compat import get_loop_param 11 | from ..protocols.xiaomi import XiaomiCipherMixin 12 | from ..utils import format_binary 13 | from .base import BINARY_SENSOR_DOMAIN, SENSOR_DOMAIN, ConnectionMode, Device 14 | from .uuids import SOFTWARE_VERSION 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | UUID_SERVICE_KETTLE = uuid.UUID('0000fe95-0000-1000-8000-00805f9b34fb') 20 | UUID_SERVICE_KETTLE_DATA = uuid.UUID("01344736-0000-1000-8000-262837236156") 21 | UUID_AUTH_INIT = uuid.UUID('00000010-0000-1000-8000-00805f9b34fb') 22 | UUID_AUTH = uuid.UUID('00000001-0000-1000-8000-00805f9b34fb') 23 | UUID_VER = uuid.UUID('00000004-0000-1000-8000-00805f9b34fb') 24 | UUID_STATUS = uuid.UUID('0000aa02-0000-1000-8000-00805f9b34fb') 25 | 26 | TEMPERATURE_ENTITY = 'temperature' 27 | KETTLE_ENTITY = 'kettle' 28 | HEAT_ENTITY = 'heat' 29 | AUTH_MAGIC1 = bytes([0x90, 0xCA, 0x85, 0xDE]) 30 | AUTH_MAGIC2 = bytes([0x92, 0xAB, 0x54, 0xFA]) 31 | 32 | HANDLE_AUTH = 36 33 | HANDLE_STATUS = 60 34 | 35 | 36 | class Mode(Enum): 37 | IDLE = 0x00 38 | HEATING = 0x01 39 | COOLING = 0x02 40 | KEEP_WARM = 0x03 41 | 42 | 43 | class LEDMode(Enum): 44 | BOIL = 0x01 45 | KEEP_WARM = 0x02 46 | NONE = 0xFF 47 | 48 | 49 | class KeepWarmType(Enum): 50 | BOIL_AND_COOLDOWN = 0x00 51 | HEAT_TO_TEMP = 0x01 52 | 53 | 54 | @dataclass 55 | class MiKettleState: 56 | mode: Mode = Mode.IDLE 57 | led_mode: LEDMode = LEDMode.NONE 58 | temperature: int = 0 59 | target_temperature: int = 0 60 | keep_warm_type: KeepWarmType = KeepWarmType.BOIL_AND_COOLDOWN 61 | keep_warm_time: int = 0 62 | 63 | FORMAT = ' self.SEND_INTERVAL or 205 | new_state != prev_state 206 | ): 207 | send_time = time.time() 208 | prev_state = new_state 209 | await self._notify_state(publish_topic) 210 | await aio.sleep(self.ACTIVE_SLEEP_INTERVAL) 211 | -------------------------------------------------------------------------------- /ble2mqtt/devices/presence.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import typing as ty 3 | from dataclasses import dataclass 4 | from datetime import datetime, timedelta 5 | 6 | from bleak.backends.device import BLEDevice 7 | 8 | from .base import BINARY_SENSOR_DOMAIN, DEVICE_TRACKER_DOMAIN, Sensor 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | @dataclass 14 | class SensorState: 15 | presence: bool = False 16 | last_check: ty.Optional[datetime] = None 17 | 18 | @property 19 | def device_tracker(self): 20 | return 'home' if self.presence else 'not_home' 21 | 22 | 23 | class Presence(Sensor): 24 | NAME = 'presence' 25 | SENSOR_CLASS = SensorState 26 | SUPPORT_PASSIVE = True 27 | SUPPORT_ACTIVE = False 28 | MANUFACTURER = 'Generic' 29 | THRESHOLD = 300 # if no activity more than THRESHOLD, consider presence=OFF 30 | DEFAULT_PASSIVE_SLEEP_INTERVAL = 1 31 | SEND_DATA_PERIOD = 60 32 | 33 | def __init__(self, *args, **kwargs): 34 | super().__init__(*args, **kwargs) 35 | cls = self.SENSOR_CLASS 36 | self._state: cls = None 37 | self._threshold = int(kwargs.get('threshold', self.THRESHOLD)) 38 | 39 | @property 40 | def entities(self): 41 | return { 42 | BINARY_SENSOR_DOMAIN: [ 43 | { 44 | 'name': 'presence', 45 | 'device_class': 'presence', 46 | }, 47 | ], 48 | DEVICE_TRACKER_DOMAIN: [ 49 | { 50 | 'name': 'device_tracker', 51 | }, 52 | ], 53 | } 54 | 55 | def handle_advert(self, scanned_device: BLEDevice, adv_data): 56 | self._state = self.SENSOR_CLASS( 57 | presence=True, 58 | last_check=datetime.now(), 59 | ) 60 | _LOGGER.debug( 61 | f'Advert received for {self}, current state: {self._state}', 62 | ) 63 | 64 | async def handle_passive(self, *args, **kwargs): 65 | self.last_sent_value = None 66 | self.last_sent_time = None 67 | await super().handle_passive(*args, **kwargs) 68 | 69 | async def do_passive_loop(self, publish_topic): 70 | if self._state.presence and \ 71 | self._state.last_check + \ 72 | timedelta(seconds=self._threshold) < datetime.now(): 73 | self._state.presence = False 74 | # send if changed or update value every SEND_DATA_PERIOD secs 75 | if self.last_sent_value is None or \ 76 | self.last_sent_value != self._state.presence or \ 77 | (datetime.now() - self.last_sent_time).seconds > \ 78 | self.SEND_DATA_PERIOD: 79 | 80 | _LOGGER.debug(f'Try publish {self._state}') 81 | await self._notify_state(publish_topic) 82 | self.last_sent_value = self._state.presence 83 | self.last_sent_time = datetime.now() 84 | -------------------------------------------------------------------------------- /ble2mqtt/devices/qingping_cgdk2.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import uuid 3 | from dataclasses import dataclass 4 | 5 | from .base import ConnectionMode 6 | from .uuids import BATTERY 7 | from .xiaomi_base import XiaomiHumidityTemperature 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | SERVICE_DATA_UUID = uuid.UUID('0000fdcd-0000-1000-8000-00805f9b34fb') 12 | 13 | 14 | @dataclass 15 | class SensorState: 16 | battery: int = 0 17 | temperature: float = 0.0 18 | humidity: float = 0.0 19 | 20 | 21 | class QingpingTempRHMonitorLite(XiaomiHumidityTemperature): 22 | NAME = 'qingpingCGDK2' 23 | MANUFACTURER = 'Qingping' 24 | DATA_CHAR = SERVICE_DATA_UUID 25 | BATTERY_CHAR = BATTERY 26 | SENSOR_CLASS = SensorState 27 | SUPPORT_PASSIVE = True 28 | ACTIVE_CONNECTION_MODE = ConnectionMode.ACTIVE_POLL_WITH_DISCONNECT 29 | 30 | PREAMBLE = b'\xcd\xfd' 31 | 32 | def filter_notifications(self, sender, data): 33 | packet_start = data.find(self.PREAMBLE) 34 | if packet_start == -1: 35 | return False 36 | return data[packet_start + len(self.PREAMBLE) + 1] == 0x10 37 | 38 | def process_data(self, data): 39 | packet_start = data.find(self.PREAMBLE) 40 | offset = packet_start + len(self.PREAMBLE) 41 | value_data = data[offset:] 42 | 43 | self._state = self.SENSOR_CLASS( 44 | temperature=int.from_bytes( 45 | value_data[10:12], 46 | byteorder='little', 47 | signed=True, 48 | ) / 10, 49 | humidity=int.from_bytes( 50 | value_data[12:14], 51 | byteorder='little', 52 | signed=False, 53 | ) / 10, 54 | battery=value_data[16], 55 | ) 56 | -------------------------------------------------------------------------------- /ble2mqtt/devices/ruuvitag.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | 4 | from bleak.backends.device import BLEDevice 5 | 6 | from ..devices.base import SENSOR_DOMAIN, Sensor 7 | from ..protocols.ruuvi import DataFormat5Decoder 8 | from ..utils import cr2477_voltage_to_percent, format_binary 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | @dataclass 14 | class SensorState: 15 | battery: int = 0 16 | temperature: float = 0 17 | humidity: float = 0 18 | pressure: float = 0 19 | movement_counter: int = 0 20 | 21 | 22 | class RuuviTag(Sensor): 23 | NAME = 'ruuvitag' 24 | MANUFACTURER = 'Ruuvi' 25 | SENSOR_CLASS = SensorState 26 | SUPPORT_PASSIVE = True 27 | SUPPORT_ACTIVE = False 28 | # send data only if temperature or humidity is set 29 | REQUIRED_VALUES = ('temperature', 'humidity', 'pressure') 30 | 31 | @property 32 | def entities(self): 33 | return { 34 | SENSOR_DOMAIN: [ 35 | { 36 | 'name': 'temperature', 37 | 'device_class': 'temperature', 38 | 'unit_of_measurement': '\u00b0C', 39 | }, 40 | { 41 | 'name': 'humidity', 42 | 'device_class': 'humidity', 43 | 'unit_of_measurement': '%', 44 | }, 45 | { 46 | 'name': 'pressure', 47 | 'device_class': 'atmospheric_pressure', 48 | 'unit_of_measurement': 'hPa', 49 | }, 50 | { 51 | 'name': 'movement_counter', 52 | }, 53 | { 54 | 'name': 'battery', 55 | 'device_class': 'battery', 56 | 'unit_of_measurement': '%', 57 | 'entity_category': 'diagnostic', 58 | }, 59 | ], 60 | } 61 | 62 | def handle_advert(self, scanned_device: BLEDevice, adv_data): 63 | raw_data = adv_data.manufacturer_data[0x0499] 64 | 65 | data_format = raw_data[0] 66 | if data_format != 0x05: 67 | _LOGGER.debug("Data format not supported: %s", raw_data[0]) 68 | return 69 | 70 | if raw_data: 71 | decoder = DataFormat5Decoder(bytes(raw_data)) 72 | self._state = self.SENSOR_CLASS( 73 | temperature=decoder.temperature_celsius, 74 | humidity=decoder.humidity_percentage, 75 | pressure=decoder.pressure_hpa, 76 | movement_counter=decoder.movement_counter, 77 | battery=int(cr2477_voltage_to_percent( 78 | decoder.battery_voltage_mv, 79 | )) 80 | ) 81 | 82 | _LOGGER.debug( 83 | f'Advert received for {self}, {format_binary(raw_data)}, ' 84 | f'current state: {self._state}', 85 | ) 86 | 87 | 88 | class RuuviTagPro2in1(RuuviTag): 89 | NAME = 'ruuvitag_pro_2in1' 90 | 91 | @property 92 | def entities(self): 93 | return { 94 | SENSOR_DOMAIN: [ 95 | { 96 | 'name': 'temperature', 97 | 'device_class': 'temperature', 98 | 'unit_of_measurement': '\u00b0C', 99 | }, 100 | { 101 | 'name': 'movement_counter', 102 | 'device_class': 'count', 103 | }, 104 | { 105 | 'name': 'battery', 106 | 'device_class': 'battery', 107 | 'unit_of_measurement': '%', 108 | 'entity_category': 'diagnostic', 109 | }, 110 | ], 111 | } 112 | 113 | 114 | class RuuviTagPro3in1(RuuviTag): 115 | NAME = 'ruuvitag_pro_3in1' 116 | 117 | @property 118 | def entities(self): 119 | return { 120 | SENSOR_DOMAIN: [ 121 | { 122 | 'name': 'temperature', 123 | 'device_class': 'temperature', 124 | 'unit_of_measurement': '\u00b0C', 125 | }, 126 | { 127 | 'name': 'humidity', 128 | 'device_class': 'humidity', 129 | 'unit_of_measurement': '%', 130 | }, 131 | { 132 | 'name': 'movement_counter', 133 | 'device_class': 'count', 134 | }, 135 | { 136 | 'name': 'battery', 137 | 'device_class': 'battery', 138 | 'unit_of_measurement': '%', 139 | 'entity_category': 'diagnostic', 140 | }, 141 | ], 142 | } 143 | -------------------------------------------------------------------------------- /ble2mqtt/devices/thermostat_ensto.py: -------------------------------------------------------------------------------- 1 | import asyncio as aio 2 | import logging 3 | import typing as ty 4 | import uuid 5 | from dataclasses import dataclass 6 | 7 | from ble2mqtt.protocols.ensto import ActiveMode, EnstoProtocol, Measurements 8 | 9 | from ..utils import format_binary 10 | from .base import ( 11 | BINARY_SENSOR_DOMAIN, 12 | SENSOR_DOMAIN, 13 | BaseClimate, 14 | ClimateMode, 15 | ConnectionMode, 16 | ) 17 | from .uuids import DEVICE_NAME, SOFTWARE_VERSION 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | UUID_CHILD_LOCK = uuid.UUID('6e3064e2-d9a5-4ca0-9d14-017c59627330') 22 | UUID_MEASUREMENTS = uuid.UUID('66ad3e6b-3135-4ada-bb2b-8b22916b21d4') 23 | UUID_VACATION = uuid.UUID('6584e9c6-4784-41aa-ac09-c899191048ae') 24 | UUID_DATE = uuid.UUID('b43f918a-b084-45c8-9b60-df648c4a4a1e') 25 | UUID_HEATING_POWER = uuid.UUID('53b7bf87-6cf0-4790-839a-e72d3afbec44') 26 | UUID_FACTORY_RESET = uuid.UUID('f366dddb-ebe2-43ee-83c0-472ded74c8fa') 27 | 28 | 29 | RELAY_ENTITY = 'relay' 30 | TARGET_TEMPERATURE_ENTITY = 'target_temperature' 31 | FLOOR_TEMPERATURE_ENTITY = 'floor_temperature' 32 | ROOM_TEMPERATURE_ENTITY = 'room_temperature' 33 | 34 | 35 | @dataclass 36 | class EnstoState: 37 | mode: ClimateMode = ClimateMode.OFF 38 | temperature: ty.Optional[float] = None 39 | target_temperature: ty.Optional[float] = None 40 | floor_temperature: ty.Optional[float] = None 41 | room_temperature: ty.Optional[float] = None 42 | relay_is_on: bool = False 43 | target_temperature_with_offset: ty.Optional[float] = None 44 | 45 | 46 | class EnstoThermostat(EnstoProtocol, BaseClimate): 47 | NAME = 'ensto_thermostat' # EPHBEBT 48 | MANUFACTURER = 'Ensto' 49 | ACTIVE_CONNECTION_MODE = ConnectionMode.ACTIVE_KEEP_CONNECTION 50 | 51 | AUTH_CHAR = UUID_FACTORY_RESET 52 | DATE_CHAR = UUID_DATE 53 | VACATION_CHAR = UUID_VACATION 54 | MEASUREMENTS_CHAR = UUID_MEASUREMENTS 55 | CUSTOM_MEMORY_SLOT_CHAR = UUID_HEATING_POWER 56 | DEFAULT_TARGET_TEMPERATURE = 18.0 57 | MIN_POTENTIOMETER_VALUE = 5.0 58 | 59 | SEND_DATA_PERIOD = 60 60 | 61 | MODES = (ClimateMode.OFF, ClimateMode.HEAT) 62 | 63 | def __init__(self, *args, key='', **kwargs): 64 | super().__init__(*args, **kwargs) 65 | self._state = EnstoState() 66 | if key: 67 | assert len(key) == 8, f'{self}: Key must be 8 chars long' 68 | self._reset_id = bytes.fromhex(key) 69 | self.initial_status_sent = False 70 | 71 | @property 72 | def entities(self): 73 | return { 74 | **super().entities, 75 | BINARY_SENSOR_DOMAIN: [ 76 | { 77 | 'name': RELAY_ENTITY, 78 | 'device_class': 'power', 79 | 'entity_category': 'diagnostic', 80 | } 81 | ], 82 | SENSOR_DOMAIN: [ 83 | { 84 | 'name': TARGET_TEMPERATURE_ENTITY, 85 | 'device_class': 'temperature', 86 | 'unit_of_measurement': '\u00b0C', 87 | }, 88 | { 89 | 'name': FLOOR_TEMPERATURE_ENTITY, 90 | 'device_class': 'temperature', 91 | 'unit_of_measurement': '\u00b0C', 92 | }, 93 | { 94 | 'name': ROOM_TEMPERATURE_ENTITY, 95 | 'device_class': 'temperature', 96 | 'unit_of_measurement': '\u00b0C', 97 | } 98 | ], 99 | } 100 | 101 | async def get_device_data(self): 102 | await super().get_device_data() 103 | _LOGGER.debug(f'{self} start protocol') 104 | await self.protocol_start() 105 | name = await self._read_with_timeout(DEVICE_NAME) 106 | if isinstance(name, (bytes, bytearray)): 107 | self._model = name[1:].decode('latin1').strip(' \0') or 'Heater' 108 | version = await self.client.read_gatt_char(SOFTWARE_VERSION) 109 | if version: 110 | self._version = version.decode('latin1') 111 | _LOGGER.debug(f'{self} name: {self._model}, version: {self._version}') 112 | 113 | self._state.target_temperature = await self.read_target_temp() 114 | _LOGGER.debug(f'{self} target temp: {self._state}') 115 | await self.guess_potentiometer_position() 116 | await self._update_state() 117 | previously_saved_target_temp = await self.read_target_temp() 118 | if previously_saved_target_temp: 119 | self._state.target_temperature = previously_saved_target_temp 120 | 121 | async def guess_potentiometer_position(self): 122 | _LOGGER.debug(f'{self} guess_potentiometer_position') 123 | values = await self.read_measurements() 124 | cur_vacation_mode = await self.read_vacation_mode() 125 | self.set_current_potentiometer_value(values, cur_vacation_mode) 126 | _LOGGER.debug( 127 | f'{self} update _heater_potentiometer_temperature ' 128 | f'{self._heater_potentiometer_temperature}', 129 | ) 130 | 131 | async def _set_target_temperature(self, value): 132 | await self.save_target_temp(value) 133 | # set vacation mode and offset 134 | if self._state.mode != ClimateMode.OFF: 135 | await self.set_vacation_mode(value) 136 | self._state.target_temperature = value 137 | await self._update_state() 138 | 139 | async def _switch_mode(self, next_mode: ClimateMode): 140 | # set vacation mode and offset 141 | if next_mode == ClimateMode.HEAT: 142 | temp = await self.read_target_temp() 143 | if not temp: 144 | temp = self.DEFAULT_TARGET_TEMPERATURE 145 | await self.save_target_temp(temp) 146 | else: 147 | temp = 5.0 148 | await self.set_vacation_mode(temp, True) 149 | await self._update_state() 150 | 151 | def get_values_by_entities(self) -> ty.Dict[str, ty.Any]: 152 | return { 153 | self.CLIMATE_ENTITY: { 154 | 'mode': self._state.mode.value, 155 | 'temperature': self._state.temperature, 156 | 'target_temperature': self._state.target_temperature, 157 | }, 158 | RELAY_ENTITY: { 159 | 'relay': 'ON' if self._state.relay_is_on else 'OFF', 160 | }, 161 | TARGET_TEMPERATURE_ENTITY: { 162 | 'target_temperature': self._state.target_temperature, 163 | }, 164 | FLOOR_TEMPERATURE_ENTITY: { 165 | 'floor_temperature': self._state.floor_temperature, 166 | }, 167 | ROOM_TEMPERATURE_ENTITY: { 168 | 'room_temperature': self._state.room_temperature, 169 | } 170 | } 171 | 172 | def set_current_potentiometer_value(self, measurements: Measurements, 173 | vacation_data: bytes): 174 | offset_temp = int.from_bytes( 175 | vacation_data[10:12], 176 | byteorder='little', 177 | signed=True, 178 | )/100 179 | vacation_enabled = vacation_data[13] 180 | _LOGGER.debug( 181 | f'{self} vacation_data: ' 182 | f'offset_temp={offset_temp}, vacation_enabled={vacation_enabled}', 183 | ) 184 | if measurements.active_mode == ActiveMode.MANUAL: 185 | self._heater_potentiometer_temperature = \ 186 | measurements.target_temperature 187 | elif measurements.active_mode == ActiveMode.VACATION: 188 | self._heater_potentiometer_temperature = \ 189 | measurements.target_temperature - offset_temp 190 | 191 | async def _update_state(self): 192 | values = await self.read_measurements() 193 | _LOGGER.debug(f'{self} parsed measurements: {values}') 194 | self._state.temperature = values.temperature 195 | self._state.target_temperature_with_offset = values.target_temperature 196 | self._state.floor_temperature = values.floor_temperature 197 | self._state.room_temperature = values.room_temperature 198 | self._state.relay_is_on = values.relay_is_on 199 | vacation_data = await self.read_vacation_mode() 200 | _LOGGER.debug(f'{self} vacation_data: {format_binary(vacation_data)}') 201 | self.set_current_potentiometer_value(values, vacation_data) 202 | 203 | if ( 204 | values.active_mode != ActiveMode.VACATION or 205 | self._state.target_temperature_with_offset > 206 | self.MIN_POTENTIOMETER_VALUE 207 | ): 208 | self._state.mode = ClimateMode.HEAT 209 | self._state.target_temperature = \ 210 | self._state.target_temperature_with_offset 211 | else: 212 | self._state.mode = ClimateMode.OFF 213 | 214 | async def handle(self, publish_topic, send_config, *args, **kwargs): 215 | timer = 0 216 | while True: 217 | await self.update_device_data(send_config) 218 | 219 | timer += self.ACTIVE_SLEEP_INTERVAL 220 | if not self.initial_status_sent or \ 221 | timer >= self.SEND_DATA_PERIOD: 222 | _LOGGER.debug(f'[{self}] check for measurements') 223 | await self._update_state() 224 | await self._notify_state(publish_topic) 225 | timer = 0 226 | await aio.sleep(self.ACTIVE_SLEEP_INTERVAL) 227 | -------------------------------------------------------------------------------- /ble2mqtt/devices/uuids.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | DEVICE_NAME = uuid.UUID('00002a00-0000-1000-8000-00805f9b34fb') 4 | BATTERY = uuid.UUID('00002a19-0000-1000-8000-00805f9b34fb') 5 | FIRMWARE_VERSION = uuid.UUID('00002a26-0000-1000-8000-00805f9b34fb') 6 | SOFTWARE_VERSION = uuid.UUID('00002a28-0000-1000-8000-00805f9b34fb') 7 | ENVIRONMENTAL_SENSING = uuid.UUID('0000181a-0000-1000-8000-00805f9b34fb') 8 | -------------------------------------------------------------------------------- /ble2mqtt/devices/voltage_bm2.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from ..devices.base import (SENSOR_DOMAIN, ConnectionMode, Sensor, 4 | SubscribeAndSetDataMixin) 5 | 6 | UUID_KEY_READ = "0000fff4-0000-1000-8000-00805f9b34fb" 7 | KEY = b"\x6c\x65\x61\x67\x65\x6e\x64\xff\xfe\x31\x38\x38\x32\x34\x36\x36" 8 | 9 | 10 | def create_aes(): 11 | try: 12 | from Crypto.Cipher import AES 13 | except ImportError: 14 | raise ImportError( 15 | "Please install pycryptodome to setup BM2 Voltage meter", 16 | ) from None 17 | 18 | return AES.new(KEY, AES.MODE_CBC, bytes([0] * 16)) 19 | 20 | 21 | @dataclass 22 | class SensorState: 23 | voltage: float 24 | 25 | @classmethod 26 | def from_data(cls, decrypted_data: bytes): 27 | voltage = (int.from_bytes( 28 | decrypted_data[1:1 + 2], 29 | byteorder='big', 30 | ) >> 4) / 100 31 | return cls(voltage=round(voltage, 2)) 32 | 33 | 34 | class VoltageTesterBM2(SubscribeAndSetDataMixin, Sensor): 35 | NAME = 'voltage_bm2' 36 | DATA_CHAR = UUID_KEY_READ 37 | MANUFACTURER = 'BM2' 38 | SENSOR_CLASS = SensorState 39 | REQUIRED_VALUES = ('voltage', ) 40 | ACTIVE_CONNECTION_MODE = ConnectionMode.ACTIVE_POLL_WITH_DISCONNECT 41 | 42 | @property 43 | def entities(self): 44 | return { 45 | SENSOR_DOMAIN: [ 46 | { 47 | 'name': 'voltage', 48 | 'device_class': 'voltage', 49 | 'unit_of_measurement': 'V', 50 | }, 51 | ], 52 | } 53 | 54 | def process_data(self, data: bytearray): 55 | decrypted_data = create_aes().decrypt(data) 56 | if decrypted_data[0] == 0xf5: 57 | self._state = self.SENSOR_CLASS.from_data(decrypted_data) 58 | -------------------------------------------------------------------------------- /ble2mqtt/devices/vson_air_wp6003.py: -------------------------------------------------------------------------------- 1 | import asyncio as aio 2 | import logging 3 | import struct 4 | import uuid 5 | from dataclasses import dataclass 6 | 7 | from ble2mqtt.devices.base import SENSOR_DOMAIN, ConnectionMode, Sensor 8 | from ble2mqtt.devices.uuids import DEVICE_NAME 9 | from ble2mqtt.protocols.wp6003 import WP6003Protocol 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | TX_CHAR = uuid.UUID('0000fff1-0000-1000-8000-00805f9b34fb') 14 | RX_CHAR = uuid.UUID('0000fff4-0000-1000-8000-00805f9b34fb') 15 | 16 | 17 | @dataclass 18 | class SensorState: 19 | FORMAT = '>6B6H' 20 | 21 | temperature: float = 0.0 22 | tvoc: float = 0.0 23 | hcho: float = 0.0 24 | co2: int = 0 25 | 26 | @classmethod 27 | def from_bytes(cls, response): 28 | # 0a 15 02 0e 09 1e 00d4 0800 0007 0000 0100 0230 29 | ( 30 | header, # 0 31 | year, # 1 32 | month, # 2 33 | day, # 3 34 | hour, # 4 35 | minute, # 5 36 | temp, # 6-7 37 | _, # 8-9 38 | tvoc, # 10-11 39 | hcho, # 12-13 40 | _, # 14-15 41 | co2, # 16-17 42 | ) = struct.unpack(cls.FORMAT, response) 43 | if header != 0x0a: 44 | raise ValueError('Bad response') 45 | if tvoc == hcho == 0x3fff: 46 | raise ValueError('Bad value') 47 | return cls( 48 | temperature=temp/10, 49 | tvoc=tvoc/1000, 50 | hcho=hcho/1000, 51 | co2=co2, 52 | ) 53 | 54 | 55 | class VsonWP6003(WP6003Protocol, Sensor): 56 | NAME = 'wp6003' 57 | RX_CHAR = RX_CHAR 58 | TX_CHAR = TX_CHAR 59 | ACTIVE_SLEEP_INTERVAL = 20 60 | MANUFACTURER = 'Vson' 61 | ACTIVE_CONNECTION_MODE = ConnectionMode.ACTIVE_KEEP_CONNECTION 62 | READ_DATA_IN_ACTIVE_LOOP = True 63 | 64 | @property 65 | def entities(self): 66 | return { 67 | SENSOR_DOMAIN: [ 68 | { 69 | 'name': 'temperature', 70 | 'device_class': 'temperature', 71 | 'unit_of_measurement': '\u00b0C', 72 | }, 73 | { 74 | 'name': 'tvoc', 75 | 'unit_of_measurement': 'mg/m³', 76 | 'icon': 'air-filter', 77 | }, 78 | { 79 | 'name': 'hcho', 80 | 'unit_of_measurement': 'mg/m³', 81 | 'icon': 'air-filter', 82 | }, 83 | { 84 | 'name': 'co2', 85 | 'unit_of_measurement': 'ppm', 86 | 'icon': 'molecule-co2', 87 | }, 88 | ], 89 | } 90 | 91 | async def read_state(self): 92 | response = await self.read_value() 93 | for _ in range(5): 94 | try: 95 | self._state = SensorState.from_bytes(response) 96 | except ValueError as e: 97 | _LOGGER.warning(f'{self} {repr(e)}') 98 | await aio.sleep(1) 99 | else: 100 | break 101 | 102 | async def get_device_data(self): 103 | # ignore reading firmware version, it doesn't support it 104 | name = await self._read_with_timeout(DEVICE_NAME) 105 | if isinstance(name, (bytes, bytearray)): 106 | self._model = name.decode().strip('\0') 107 | await self.protocol_start() 108 | await self.send_reset() 109 | await self.write_time() 110 | 111 | async def do_active_loop(self, publish_topic): 112 | try: 113 | await aio.wait_for(self.read_state(), 5) 114 | await self._notify_state(publish_topic) 115 | except (aio.CancelledError, KeyboardInterrupt): 116 | raise 117 | except Exception: 118 | _LOGGER.exception(f'{self} problem with reading values') 119 | -------------------------------------------------------------------------------- /ble2mqtt/devices/xiaomi_base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import logging 3 | import typing as ty 4 | 5 | from ..protocols.xiaomi import XiaomiPoller 6 | from .base import SENSOR_DOMAIN 7 | 8 | _LOGGER = logging.getLogger(__name__) 9 | 10 | 11 | class XiaomiHumidityTemperature(XiaomiPoller, abc.ABC): 12 | SENSOR_CLASS: ty.Any = None 13 | # send data only if temperature or humidity is set 14 | REQUIRED_VALUES = ('temperature', 'humidity') 15 | 16 | @property 17 | def entities(self): 18 | return { 19 | SENSOR_DOMAIN: [ 20 | { 21 | 'name': 'temperature', 22 | 'device_class': 'temperature', 23 | 'unit_of_measurement': '\u00b0C', 24 | }, 25 | { 26 | 'name': 'humidity', 27 | 'device_class': 'humidity', 28 | 'unit_of_measurement': '%', 29 | }, 30 | { 31 | 'name': 'battery', 32 | 'device_class': 'battery', 33 | 'unit_of_measurement': '%', 34 | 'entity_category': 'diagnostic', 35 | }, 36 | ], 37 | } 38 | 39 | async def read_and_send_data(self, publish_topic): 40 | battery = await self._read_with_timeout(self.BATTERY_CHAR) 41 | data_bytes = await self._stack.get() 42 | # clear queue 43 | while not self._stack.empty(): 44 | self._stack.get_nowait() 45 | self._state = self.SENSOR_CLASS.from_data(data_bytes, battery) 46 | await self._notify_state(publish_topic) 47 | -------------------------------------------------------------------------------- /ble2mqtt/devices/xiaomi_ht.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import typing as ty 3 | import uuid 4 | from dataclasses import dataclass 5 | 6 | from bleak.backends.device import BLEDevice 7 | 8 | from ..protocols.xiaomi import parse_fe95_advert 9 | from ..utils import format_binary 10 | from .base import ConnectionMode 11 | from .uuids import BATTERY 12 | from .xiaomi_base import XiaomiHumidityTemperature 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | MJHT_DATA = uuid.UUID('226caa55-6476-4566-7562-66734470666d') 17 | ADVERTISING = uuid.UUID('0000fe95-0000-1000-8000-00805f9b34fb') 18 | 19 | 20 | @dataclass 21 | class SensorState: 22 | battery: int = 0 23 | temperature: float = 0 24 | humidity: float = 0 25 | voltage: ty.Optional[float] = None 26 | 27 | @classmethod 28 | def from_data(cls, sensor_data, battery_data): 29 | # b'T=23.6 H=39.6\x00' 30 | t, h = tuple( 31 | float(x.split('=')[1]) 32 | for x in sensor_data.decode().strip('\0').split(' ') 33 | ) 34 | battery = int(ord(battery_data)) if battery_data else 0 35 | return cls( 36 | temperature=t, 37 | humidity=h, 38 | battery=battery, 39 | ) 40 | 41 | 42 | class XiaomiHumidityTemperatureV1(XiaomiHumidityTemperature): 43 | NAME = 'xiaomihtv1' 44 | DATA_CHAR = MJHT_DATA 45 | BATTERY_CHAR = BATTERY 46 | SENSOR_CLASS = SensorState 47 | SUPPORT_PASSIVE = True 48 | ACTIVE_CONNECTION_MODE = ConnectionMode.ACTIVE_POLL_WITH_DISCONNECT 49 | 50 | def filter_notifications(self, sender, data): 51 | # sender is 0xd or several requests it becomes 52 | # /org/bluez/hci0/dev_58_2D_34_32_E0_69/service000c/char000d 53 | return ( 54 | sender == 0xd or 55 | isinstance(sender, str) and sender.endswith('000d') 56 | ) 57 | 58 | def handle_advert(self, scanned_device: BLEDevice, adv_data): 59 | service_data = adv_data.service_data 60 | adv_data = service_data.get(str(ADVERTISING)) 61 | 62 | if adv_data: 63 | # frctrl devic id <----- mac -----> type len <-- data --> 64 | # [50 20 aa 01 e4 69 e0 32 34 2d 58 0d 10 04 df 00 55 01] 65 | # [50 20 aa 01 80 69 e0 32 34 2d 58 0d 10 04 d6 00 29 01] 66 | 67 | try: 68 | parsed_advert = parse_fe95_advert(bytes(adv_data)) 69 | except (ValueError, IndexError): 70 | _LOGGER.exception(f'{self} Cannot parse advert packet') 71 | return 72 | 73 | if self._state is None: 74 | self._state = self.SENSOR_CLASS() 75 | for k, v in parsed_advert.items(): 76 | setattr(self._state, k, v) 77 | 78 | _LOGGER.debug( 79 | f'Advert received for {self}, {format_binary(adv_data)}, ' 80 | f'current state: {self._state}', 81 | ) 82 | -------------------------------------------------------------------------------- /ble2mqtt/devices/xiaomi_lywsd03.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import struct 3 | import uuid 4 | from dataclasses import dataclass 5 | 6 | from ..utils import cr2032_voltage_to_percent 7 | from .base import ConnectionMode 8 | from .xiaomi_base import XiaomiHumidityTemperature 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | LYWSD_DATA = uuid.UUID('EBE0CCC1-7A0A-4B0C-8A1A-6FF2997DA3A6') 13 | LYWSD_BATTERY = uuid.UUID('EBE0CCC4-7A0A-4B0C-8A1A-6FF2997DA3A6') 14 | 15 | 16 | @dataclass 17 | class SensorState: 18 | battery: int 19 | temperature: float 20 | humidity: float 21 | 22 | @classmethod 23 | def from_data(cls, sensor_data, battery_data): 24 | t, h, voltage = struct.unpack(' None: 19 | super().__init__(mac, *args, **kwargs) 20 | self._sends_custom = False # support decimal value for humidity 21 | 22 | def handle_advert(self, scanned_device: BLEDevice, adv_data): 23 | service_data = adv_data.service_data 24 | adv_data = service_data.get(str(ENVIRONMENTAL_SENSING)) 25 | 26 | if adv_data: 27 | if len(adv_data) == 15: 28 | # b'\xe6o\xb98\xc1\xa4\x95\t\xff\x08~\x0cd\xe0\x04' 29 | self._sends_custom = True 30 | adv_data = bytes(adv_data) 31 | self._state = self.SENSOR_CLASS( 32 | temperature=int.from_bytes( 33 | adv_data[6:8], byteorder='little', signed=True) / 100, 34 | humidity=int.from_bytes( 35 | adv_data[8:10], byteorder='little') / 100, 36 | battery=adv_data[12], 37 | ) 38 | 39 | elif len(adv_data) == 13: 40 | if self._sends_custom: 41 | # ignore low res humidity packets 42 | return 43 | # [a4 c1 38 84 7e 97 01 26 15 50 0b 73 17] 44 | # <----- mac -----> temp hum bat 45 | adv_data = bytes(adv_data) 46 | self._state = self.SENSOR_CLASS( 47 | temperature=int.from_bytes( 48 | adv_data[6:8], byteorder='big', signed=True) / 10, 49 | humidity=adv_data[8], 50 | battery=adv_data[9], 51 | ) 52 | _LOGGER.debug( 53 | f'Advert received for {self}, {format_binary(adv_data)}, ' 54 | f'current state: {self._state}', 55 | ) 56 | -------------------------------------------------------------------------------- /ble2mqtt/exceptions.py: -------------------------------------------------------------------------------- 1 | import asyncio as aio 2 | import logging 3 | import os 4 | from contextlib import asynccontextmanager 5 | 6 | import aio_mqtt 7 | from bleak import BleakError 8 | 9 | ListOfConnectionErrors = ( 10 | BleakError, 11 | aio.TimeoutError, 12 | 13 | # dbus-next exceptions: 14 | # AttributeError: 'NoneType' object has no attribute 'call' 15 | AttributeError, 16 | # https://github.com/hbldh/bleak/issues/409 17 | EOFError, 18 | ) 19 | 20 | ListOfMQTTConnectionErrors = ( 21 | aio_mqtt.ConnectionLostError, 22 | aio_mqtt.ConnectionClosedError, 23 | aio_mqtt.ServerDiedError, 24 | BrokenPipeError, 25 | ) 26 | 27 | 28 | _LOGGER = logging.getLogger(__name__) 29 | 30 | # initialize in a loop 31 | BLUETOOTH_RESTARTING = aio.Lock() 32 | 33 | 34 | def hardware_exception_occurred(exception): 35 | ex_str = str(exception) 36 | return ( 37 | 'org.freedesktop.DBus.Error.ServiceUnknown' in ex_str or 38 | 'org.freedesktop.DBus.Error.NoReply' in ex_str or 39 | 'org.freedesktop.DBus.Error.AccessDenied' in ex_str or 40 | 'org.bluez.Error.Failed: Connection aborted' in ex_str or 41 | 'org.bluez.Error.NotReady' in ex_str or 42 | 'org.bluez.Error.InProgress' in ex_str 43 | ) 44 | 45 | 46 | async def restart_bluetooth(adapter: str): 47 | if BLUETOOTH_RESTARTING.locked(): 48 | await aio.sleep(9) 49 | return 50 | async with BLUETOOTH_RESTARTING: 51 | _LOGGER.warning('Restarting bluetoothd...') 52 | proc = await aio.create_subprocess_exec( 53 | 'hciconfig', adapter, 'down', 54 | ) 55 | await proc.wait() 56 | 57 | if os.path.exists('/etc/init.d/bluetoothd'): 58 | proc = await aio.create_subprocess_exec( 59 | '/etc/init.d/bluetoothd', 'restart', 60 | ) 61 | await proc.wait() 62 | 63 | elif os.path.exists('/etc/init.d/bluetooth'): 64 | proc = await aio.create_subprocess_exec( 65 | '/etc/init.d/bluetooth', 'restart', 66 | ) 67 | await proc.wait() 68 | 69 | else: 70 | _LOGGER.error('init.d bluetoothd script not found') 71 | 72 | await aio.sleep(3) 73 | proc = await aio.create_subprocess_exec( 74 | 'hciconfig', adapter, 'up', 75 | ) 76 | await proc.wait() 77 | await aio.sleep(5) 78 | _LOGGER.warning('Restarting bluetoothd finished') 79 | 80 | 81 | @asynccontextmanager 82 | async def handle_ble_exceptions(adapter: str): 83 | try: 84 | yield 85 | except ListOfConnectionErrors as e: 86 | if hardware_exception_occurred(e): 87 | await restart_bluetooth(adapter) 88 | await aio.sleep(3) 89 | raise 90 | -------------------------------------------------------------------------------- /ble2mqtt/protocols/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devbis/ble2mqtt/1e95f1e3e0c324fbd9eb294fa7812d8b3a47f702/ble2mqtt/protocols/__init__.py -------------------------------------------------------------------------------- /ble2mqtt/protocols/am43.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import logging 3 | import uuid 4 | 5 | from ..devices.base import BaseDevice 6 | from ..utils import format_binary 7 | from .base import BLEQueueMixin 8 | 9 | _LOGGER = logging.getLogger(__name__) 10 | 11 | # command IDs 12 | AM43_CMD_MOVE = 0x0a 13 | AM43_CMD_GET_BATTERY = 0xa2 14 | AM43_CMD_GET_ILLUMINANCE = 0xaa 15 | AM43_CMD_GET_POSITION = 0xa7 16 | AM43_CMD_SET_POSITION = 0x0d 17 | AM43_NOTIFY_POSITION = 0xa1 18 | 19 | AM43_RESPONSE_ACK = 0x5a 20 | AM43_RESPONSE_NACK = 0xa5 21 | # 9a a8 0a 01 00 7f 09 1e 01 64 7f 11 00 5a 22 | AM43_REPLY_UNKNOWN1 = 0xa8 23 | # 9a a9 10 00 00 00 11 00 00 00 00 01 00 00 11 00 00 00 00 22 24 | AM43_REPLY_UNKNOWN2 = 0xa9 25 | 26 | 27 | class AM43Protocol(BLEQueueMixin, BaseDevice, abc.ABC): 28 | DATA_CHAR: uuid.UUID = None # type: ignore 29 | 30 | def notification_callback(self, sender_handle: int, data: bytearray): 31 | self.process_data(data) 32 | super().notification_callback(sender_handle, data) 33 | 34 | @staticmethod 35 | def _convert_position(value): 36 | return 100 - value 37 | 38 | @classmethod 39 | def convert_to_device(cls, value): 40 | return cls._convert_position(value) 41 | 42 | @classmethod 43 | def convert_from_device(cls, value): 44 | return cls._convert_position(value) 45 | 46 | async def send_command(self, cmd_id, data: list, 47 | wait_reply=True, timeout=25): 48 | _LOGGER.debug(f'[{self}] - send command 0x{cmd_id:x} {data}') 49 | cmd = bytearray([0x9a, cmd_id, len(data)] + data) 50 | csum = 0 51 | for x in cmd: 52 | csum = csum ^ x 53 | cmd += bytearray([csum]) 54 | 55 | self.clear_ble_queue() 56 | await self.client.write_gatt_char(self.DATA_CHAR, cmd) 57 | ret = None 58 | if wait_reply: 59 | _LOGGER.debug(f'[{self}] waiting for reply') 60 | ble_notification = await self.ble_get_notification(timeout) 61 | _LOGGER.debug(f'[{self}] reply: {repr(ble_notification[1])}') 62 | ret = bytes(ble_notification[1]) 63 | return ret 64 | 65 | async def _get_position(self): 66 | await self.send_command(AM43_CMD_GET_POSITION, [0x01], True) 67 | 68 | async def _get_battery(self): 69 | await self.send_command(AM43_CMD_GET_BATTERY, [0x01], True) 70 | 71 | async def _get_illuminance(self): 72 | await self.send_command(AM43_CMD_GET_ILLUMINANCE, [0x01], True) 73 | 74 | async def _set_position(self, value): 75 | await self.send_command( 76 | AM43_CMD_SET_POSITION, 77 | [self.convert_to_device(int(value))], 78 | True, 79 | ) 80 | 81 | async def _stop(self): 82 | await self.send_command(AM43_CMD_MOVE, [0xcc]) 83 | 84 | async def _open(self): 85 | # not used 86 | await self.send_command(AM43_CMD_MOVE, [0xdd]) 87 | 88 | async def _close(self): 89 | # not used 90 | await self.send_command(AM43_CMD_MOVE, [0xee]) 91 | 92 | async def _update_full_state(self): 93 | await self._get_position() 94 | await self._get_battery() 95 | await self._get_illuminance() 96 | 97 | @abc.abstractmethod 98 | def handle_battery(self, value): 99 | pass 100 | 101 | @abc.abstractmethod 102 | def handle_position(self, value): 103 | pass 104 | 105 | @abc.abstractmethod 106 | def handle_illuminance(self, value): 107 | pass 108 | 109 | def process_data(self, data: bytearray): 110 | if data[1] == AM43_CMD_GET_BATTERY: 111 | # b'\x9a\xa2\x05\x00\x00\x00\x00Ql' 112 | self.handle_battery(int(data[7])) 113 | elif data[1] == AM43_NOTIFY_POSITION: 114 | self.handle_position(self.convert_from_device(int(data[4]))) 115 | elif data[1] == AM43_CMD_GET_POSITION: 116 | # [9a a7 07 0e 32 00 00 00 00 30 36] 117 | # Bytes in this packet are: 118 | # 3: Configuration flags, bits are: 119 | # 1: direction 120 | # 2: operation mode 121 | # 3: top limit set 122 | # 4: bottom limit set 123 | # 5: has light sensor 124 | # 4: Speed setting 125 | # 5: Current position 126 | # 6,7: Shade length. 127 | # 8: Roller diameter. 128 | # 9: Roller type. 129 | 130 | self.handle_position(self.convert_from_device(int(data[5]))) 131 | elif data[1] == AM43_CMD_GET_ILLUMINANCE: 132 | # b'\x9a\xaa\x02\x00\x002' 133 | self.handle_illuminance(int(data[4]) * 12.5) 134 | elif data[1] in [AM43_CMD_MOVE, AM43_CMD_SET_POSITION]: 135 | if data[3] != AM43_RESPONSE_ACK: 136 | _LOGGER.error(f'[{self}] Problem with moving: NACK') 137 | elif data[1] in [AM43_REPLY_UNKNOWN1, AM43_REPLY_UNKNOWN2]: 138 | # [9a a8 00 32] 139 | # [9a a9 10 00 00 00 11 00 00 00 00 01 00 00 11 00 00 00 00 22] 140 | pass 141 | else: 142 | _LOGGER.error( 143 | f'{self} BLE notification unknown response ' 144 | f'[{format_binary(data)}]', 145 | ) 146 | -------------------------------------------------------------------------------- /ble2mqtt/protocols/avea.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import asyncio as aio 3 | import logging 4 | import struct 5 | import uuid 6 | 7 | from ..devices.base import BaseDevice 8 | from ..utils import color_rgb_to_rgbw, color_rgbw_to_rgb, format_binary 9 | from .base import BaseCommand, BLEQueueMixin, SendAndWaitReplyMixin 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | 13 | CMD_COLOR = 0x35 14 | CMD_BRIGHTNESS = 0x57 15 | CMD_NAME = 0x58 16 | 17 | 18 | class AveaCommand(BaseCommand): 19 | pass 20 | 21 | 22 | class AveaProtocol(BLEQueueMixin, SendAndWaitReplyMixin, BaseDevice, abc.ABC): 23 | DATA_CHAR: uuid.UUID = None # type: ignore 24 | 25 | async def get_device_data(self): 26 | if self.DATA_CHAR: 27 | await self.client.start_notify( 28 | self.DATA_CHAR, 29 | self.notification_callback, 30 | ) 31 | 32 | def notification_callback(self, sender_handle: int, data: bytearray): 33 | self.process_data(data) 34 | super().notification_callback(sender_handle, data) 35 | 36 | async def send_command(self, cmd: bytes = b'', 37 | wait_reply=False, timeout=10): 38 | command = AveaCommand(cmd, wait_reply=wait_reply, timeout=timeout) 39 | await self.cmd_queue.put(command) 40 | return await aio.wait_for(command.answer, timeout) 41 | 42 | async def process_command(self, command: AveaCommand): 43 | _LOGGER.debug(f'... send cmd {format_binary(command.cmd)}') 44 | self.clear_ble_queue() 45 | cmd_resp = await aio.wait_for( 46 | self.client.write_gatt_char(self.DATA_CHAR, command.cmd, True), 47 | timeout=command.timeout, 48 | ) 49 | if not command.wait_reply: 50 | if command.answer.cancelled(): 51 | return 52 | command.answer.set_result(cmd_resp) 53 | return 54 | 55 | ble_notification = await self.ble_get_notification(command.timeout) 56 | 57 | # extract payload from container 58 | cmd_resp = bytes(ble_notification[1]) 59 | if command.answer.cancelled(): 60 | return 61 | command.answer.set_result(cmd_resp) 62 | 63 | @abc.abstractmethod 64 | def handle_color(self, value): 65 | """Handle color tuple (r,g,b)""" 66 | pass 67 | 68 | @abc.abstractmethod 69 | def handle_brightness(self, value): 70 | """Handle brightness value""" 71 | pass 72 | 73 | @abc.abstractmethod 74 | def handle_name(self, value): 75 | pass 76 | 77 | def process_data(self, data: bytearray): 78 | # b'5\x00\x00\x00\x00\x00\xff\x15\x00 \xff>\x00\x00\x00\x16\x00 \x00?' 79 | if data[0] == CMD_COLOR: 80 | ( 81 | cur_white, cur_blue, cur_green, cur_red, 82 | white, blue, green, red, 83 | ) = struct.unpack(' ty.Tuple[int, bytes]: 32 | ble_response_task = aio.create_task(self._ble_queue.get()) 33 | disconnect_wait_task = aio.create_task(self.disconnected_event.wait()) 34 | await aio.wait( 35 | [ble_response_task, disconnect_wait_task], 36 | timeout=timeout, 37 | return_when=aio.FIRST_COMPLETED, 38 | ) 39 | if ble_response_task.done(): 40 | disconnect_wait_task.cancel() 41 | try: 42 | await disconnect_wait_task 43 | except aio.CancelledError: 44 | pass 45 | return await ble_response_task 46 | else: 47 | ble_response_task.cancel() 48 | try: 49 | await ble_response_task 50 | except aio.CancelledError: 51 | pass 52 | raise ConnectionError( 53 | f'{self} cannot fetch response, device is offline', 54 | ) 55 | 56 | 57 | class BaseCommand: 58 | def __init__(self, cmd, *args, wait_reply, timeout, **kwargs): 59 | self.cmd = cmd 60 | self.answer = aio.Future() 61 | self.wait_reply = wait_reply 62 | self.timeout = timeout 63 | 64 | 65 | class SendAndWaitReplyMixin(BaseDevice, abc.ABC): 66 | def __init__(self, *args, **kwargs): 67 | super().__init__(*args, **kwargs) 68 | self.cmd_queue: aio.Queue[BaseCommand] = \ 69 | aio.Queue(**get_loop_param(self._loop)) 70 | self._cmd_queue_task = aio.ensure_future( 71 | self._handle_cmd_queue(), 72 | loop=self._loop, 73 | ) 74 | self._cmd_queue_task.add_done_callback( 75 | self._queue_handler_done_callback, 76 | ) 77 | 78 | def clear_cmd_queue(self): 79 | if hasattr(self.cmd_queue, '_queue'): 80 | self.cmd_queue._queue.clear() 81 | 82 | async def _handle_cmd_queue(self): 83 | while True: 84 | command = await self.cmd_queue.get() 85 | try: 86 | await self.process_command(command) 87 | except aio.CancelledError: 88 | _LOGGER.exception(f'{self} _handle_cmd_queue is cancelled!') 89 | raise 90 | except Exception as e: 91 | if command and not command.answer.done(): 92 | command.answer.set_exception(e) 93 | _LOGGER.exception( 94 | f'{self} raise an error in handle_queue, ignore it', 95 | ) 96 | 97 | async def process_command(self, command): 98 | raise NotImplementedError() 99 | 100 | def _queue_handler_done_callback(self, future: aio.Future): 101 | exc_info = None 102 | try: 103 | exc_info = future.exception() 104 | except aio.CancelledError: 105 | pass 106 | 107 | if exc_info is not None: 108 | exc_info = ( # type: ignore 109 | type(exc_info), 110 | exc_info, 111 | exc_info.__traceback__, 112 | ) 113 | _LOGGER.exception( 114 | f'{self} _handle_cmd_queue() stopped unexpectedly', 115 | exc_info=exc_info, 116 | ) 117 | -------------------------------------------------------------------------------- /ble2mqtt/protocols/ensto.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import datetime 3 | import logging 4 | import struct 5 | import uuid 6 | from dataclasses import dataclass 7 | from enum import Enum 8 | 9 | from bleak import BleakError 10 | 11 | from ble2mqtt.devices.base import BaseDevice 12 | 13 | from ..utils import format_binary 14 | from .base import BLEQueueMixin 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | 19 | class EnstoNotAuthorized(Exception): 20 | pass 21 | 22 | 23 | class ActiveMode(Enum): 24 | MANUAL = 1 25 | CALENDAR = 2 26 | VACATION = 3 27 | 28 | 29 | class ActiveHeatingMode(Enum): 30 | FLOOR = 1 31 | ROOM = 2 32 | COMBINATION = 3 33 | POWER = 4 34 | FORCE = 5 35 | 36 | 37 | @dataclass 38 | class Measurements: 39 | target_temperature: float 40 | temperature: float 41 | floor_temperature: float 42 | room_temperature: float 43 | relay_is_on: bool 44 | alarm_code: int 45 | active_mode: ActiveMode 46 | active_heating_mode: ActiveHeatingMode 47 | boost_is_on: bool 48 | potentiometer: int 49 | boost_minutes: int = 0 50 | boost_minutes_left: int = 0 51 | 52 | 53 | class EnstoProtocol(BLEQueueMixin, BaseDevice, abc.ABC): 54 | MEASUREMENTS_CHAR: uuid.UUID = None # type: ignore 55 | VACATION_CHAR: uuid.UUID = None # type: ignore 56 | DATE_CHAR: uuid.UUID = None # type: ignore 57 | CUSTOM_MEMORY_SLOT_CHAR: uuid.UUID = None # type: ignore 58 | AUTH_CHAR: uuid.UUID = None # type: ignore 59 | 60 | def __init__(self, *args, **kwargs): 61 | self._reset_id = None 62 | super().__init__(*args, **kwargs) 63 | self._heater_potentiometer_temperature = 20.0 # temp on potentiometer 64 | 65 | async def protocol_start(self): 66 | await self.auth() 67 | await self.set_date() 68 | 69 | async def auth(self): 70 | # Need to check if key is provided. Otherwise, read the reset_ket from 71 | # characteristic 72 | if not self._reset_id: 73 | # TODO: pairing in python code doesn't work. 74 | # Use one-time bluetoothctl pairing 75 | # _LOGGER.info('pairing...') 76 | # await self.client.pair() 77 | _LOGGER.info(f'{self} reading RESET_ID_CHAR {self.AUTH_CHAR}') 78 | try: 79 | data = await self.client.read_gatt_char(self.AUTH_CHAR) 80 | except BleakError as e: 81 | if 'NotAuthorized' in str(e): 82 | raise EnstoNotAuthorized( 83 | f'{self} {self.AUTH_CHAR} is not readable.' 84 | f' Switch the thermostat in pairing mode', 85 | ) from None 86 | raise 87 | _LOGGER.debug(f'{self} reset id: {format_binary(data)}') 88 | if len(data) >= 10: 89 | self._reset_id = data[:4] 90 | _LOGGER.warning( 91 | f'{self} [!] Write key {self._reset_id.hex()} ' 92 | f'to config file for later connection', 93 | ) 94 | else: 95 | _LOGGER.error( 96 | f'{self} Key is unknown and device is not in pairing mode', 97 | ) 98 | try: 99 | await self.client.write_gatt_char( 100 | self.AUTH_CHAR, self._reset_id, response=True, 101 | ) 102 | except BleakError as e: 103 | if 'NotAuthorized' in str(e): 104 | raise EnstoNotAuthorized( 105 | f'{self} has incorrect key: {self._reset_id.hex()}', 106 | ) from None 107 | raise 108 | 109 | @staticmethod 110 | def _parse_measurements(data: bytearray) -> Measurements: 111 | # first part of reporting data 112 | target_temperature = \ 113 | int.from_bytes(data[1:3], byteorder='little') / 10 114 | room_temperature = \ 115 | int.from_bytes(data[4:6], byteorder='little', signed=True) / 10 116 | floor_temperature = \ 117 | int.from_bytes(data[6:8], byteorder='little', signed=True) / 10 118 | relay_is_on = data[8] == 1 119 | alarm_code = int.from_bytes(data[9:13], byteorder='little') 120 | 121 | # 1 - manual, 2 - calendar, 3 - vacation 122 | active_mode = ActiveMode(data[13]) 123 | active_heating_mode = ActiveHeatingMode(data[14]) 124 | boost_is_on = data[15] == 1 125 | boost_minutes = int.from_bytes(data[16:18], byteorder='little') 126 | boost_minutes_left = int.from_bytes(data[18:20], byteorder='little') 127 | potentiometer = data[20] 128 | 129 | if active_heating_mode == ActiveHeatingMode.FLOOR: 130 | temperature = floor_temperature 131 | elif active_heating_mode == ActiveHeatingMode.ROOM: 132 | temperature = room_temperature 133 | elif room_temperature == -0.1 and floor_temperature != -0.1: 134 | temperature = floor_temperature 135 | elif room_temperature != -0.1 and floor_temperature == -0.1: 136 | temperature = room_temperature 137 | else: 138 | temperature = room_temperature 139 | 140 | return Measurements( 141 | target_temperature=target_temperature, 142 | temperature=temperature, 143 | floor_temperature=floor_temperature, 144 | room_temperature=room_temperature, 145 | relay_is_on=relay_is_on, 146 | alarm_code=alarm_code, 147 | boost_is_on=boost_is_on, 148 | boost_minutes=boost_minutes, 149 | boost_minutes_left=boost_minutes_left, 150 | potentiometer=potentiometer, 151 | active_mode=active_mode, 152 | active_heating_mode=active_heating_mode 153 | ) 154 | 155 | async def read_measurements(self) -> Measurements: 156 | # f5 32 00 00 b8 00 00 00 00 00 00 00 00 01 02 00 3c 00 3c 157 | # 00 00 00 05 00 ff ff ff ff ff ff ff ff ff ff ff ff ff ff 158 | 159 | # d2 34 00 00 d8 00 00 00 00 00 00 00 00 03 02 00 3c 00 3c 160 | # 00 00 00 05 00 ff ff ff ff ff ff ff ff ff ff ff ff ff ff 161 | data = await self.client.read_gatt_char(self.MEASUREMENTS_CHAR) 162 | _LOGGER.debug(f'{self} read_measurements: {format_binary(data)}') 163 | return self._parse_measurements(data) 164 | 165 | async def set_date(self, tzoffset=3): 166 | _LOGGER.debug(f'{self} set date to current') 167 | now = datetime.datetime.utcnow() + datetime.timedelta(hours=tzoffset) 168 | await self.client.write_gatt_char( 169 | self.DATE_CHAR, 170 | struct.pack( 171 | ' bytes: 207 | # 10 07 01 00 00 10 08 01 00 00 0c fe ec 00 00 208 | data = await self.client.read_gatt_char(self.VACATION_CHAR) 209 | return data 210 | 211 | async def read_target_temp(self): 212 | return int.from_bytes(( 213 | await self.client.read_gatt_char(self.CUSTOM_MEMORY_SLOT_CHAR) 214 | )[:2], byteorder='little')/10 215 | 216 | async def save_target_temp(self, value: float): 217 | # to keep it working between app restart we store target temp 218 | # in an unused characteristic 219 | await self.client.write_gatt_char( 220 | self.CUSTOM_MEMORY_SLOT_CHAR, 221 | int(value*10).to_bytes(2, byteorder='little'), 222 | ) 223 | -------------------------------------------------------------------------------- /ble2mqtt/protocols/govee.py: -------------------------------------------------------------------------------- 1 | """ 2 | Decoder for Govee temperature/humidity sensors. 3 | Based on https://github.com/wcbonner/GoveeBTTempLogger/blob/master/goveebttemplogger.cpp (MIT License) # noqa: E501 4 | """ 5 | from __future__ import annotations 6 | from enum import Enum 7 | 8 | import struct 9 | import typing as ty 10 | 11 | class PartNumber(Enum): 12 | H5074 = 0 13 | H5075 = 1 14 | 15 | 16 | def get_intermediate_temp_h5075(unpacked_bytes): 17 | """ 18 | Decode the data and separate out the sign from the temperature. Note that 19 | the humidity and temperature are both encoded into the 24 bit returned 20 | value. 21 | """ 22 | raw = int.from_bytes(unpacked_bytes, byteorder="big") 23 | is_negative = (raw & 0x800000) > 0 24 | temp = raw & 0x7FFFF 25 | 26 | return (temp, is_negative) 27 | 28 | 29 | class GoveeDecoder: 30 | def __init__(self, raw_data: bytes) -> None: 31 | if len(raw_data) == 7: 32 | self.data = struct.unpack(" ty.Optional[float]: 44 | if self.part_number == PartNumber.H5074: 45 | if self.data[0] == -32768: 46 | return None 47 | return round(self.data[0] / 100.0, 2) 48 | 49 | elif self.part_number == PartNumber.H5075: 50 | temp, is_negative = get_intermediate_temp_h5075(self.data[0]) 51 | 52 | # This is temp/1000/10 53 | #see: https://github.com/wcbonner/GoveeBTTempLogger/issues/49 54 | temp /= 10000.0 55 | 56 | if is_negative: 57 | temp *= -1.0 58 | 59 | return temp 60 | 61 | else: 62 | return None 63 | 64 | @property 65 | def humidity_percentage(self) -> ty.Optional[float]: 66 | if self.part_number == PartNumber.H5074: 67 | if self.data[0] == -32768: 68 | return None 69 | return round(self.data[1] / 100.0, 2) 70 | 71 | elif self.part_number == PartNumber.H5075: 72 | temp, _ = get_intermediate_temp_h5075(self.data[0]) 73 | humidity = (temp % 1000.0) / 10.0 74 | return humidity 75 | 76 | else: 77 | return None 78 | 79 | @property 80 | def battery_percentage(self) -> int: 81 | if self.part_number == PartNumber.H5074: 82 | return self.data[2] 83 | 84 | elif self.part_number == PartNumber.H5075: 85 | return self.data[1] 86 | 87 | else: 88 | return None 89 | -------------------------------------------------------------------------------- /ble2mqtt/protocols/redmond.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import asyncio as aio 3 | import logging 4 | import struct 5 | import time 6 | import uuid 7 | from dataclasses import dataclass 8 | from enum import Enum, IntEnum 9 | 10 | from ..devices.base import BaseDevice 11 | from ..utils import format_binary 12 | from .base import BaseCommand, BLEQueueMixin, SendAndWaitReplyMixin 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | 17 | BOIL_TIME_RELATIVE_DEFAULT = 0x80 18 | 19 | 20 | class RedmondError(ValueError): 21 | pass 22 | 23 | 24 | class Command(IntEnum): 25 | VERSION = 0x01 26 | RUN_CURRENT_MODE = 0x03 27 | STOP_CURRENT_MODE = 0x04 28 | WRITE_MODE = 0x05 29 | READ_MODE = 0x06 30 | WRITE_TEMPERATURE = 0x0b 31 | WRITE_DELAY = 0x0c 32 | WRITE_IONIZATION = 0x1b 33 | WRITE_COLOR = 0x32 34 | READ_COLOR = 0x33 35 | SET_BACKLIGHT_MODE = 0x37 36 | SET_SOUND = 0x3c 37 | SET_LOCK = 0x3e 38 | GET_STATISTICS = 0x47 39 | GET_STARTS_COUNT = 0x50 40 | SET_TIME = 0x6e 41 | AUTH = 0xFF 42 | 43 | 44 | class KettleG200Mode(IntEnum): 45 | BOIL = 0x00 46 | HEAT = 0x01 47 | LIGHT = 0x03 48 | UNKNOWN1 = 0x04 49 | UNKNOWN2 = 0x05 50 | UNKNOWN3 = 0x06 51 | 52 | 53 | class CookerAfterCookMode(IntEnum): 54 | HEAT_AFTER_COOK = 0x00 55 | OFF_AFTER_COOK = 0x01 56 | 57 | 58 | class KettleRunState(IntEnum): 59 | OFF = 0x00 60 | SETUP_PROGRAM = 0x01 # for cooker 61 | ON = 0x02 # cooker - delayed start 62 | HEAT = 0x03 # for cooker 63 | COOKING = 0x05 # for cooker 64 | WARM_UP = 0x06 # for cooker 65 | 66 | 67 | class CookerRunState(IntEnum): 68 | OFF = 0x00 69 | SETUP_PROGRAM = 0x01 # for cooker 70 | DELAYED_START = 0x02 # cooker - delayed start 71 | HEAT = 0x03 # for cooker 72 | COOKING = 0x05 # for cooker 73 | WARM_UP = 0x06 # for cooker 74 | 75 | 76 | class ColorTarget(Enum): 77 | BOIL = 0x00 78 | LIGHT = 0x01 79 | 80 | 81 | class CookerM200Program(Enum): 82 | FRYING = 0x0 83 | RICE = 0x1 84 | MANUAL = 0x2 85 | PILAF = 0x3 86 | STEAM = 0x4 87 | BAKING = 0x5 88 | STEWING = 0x6 89 | SOUP = 0x7 90 | PORRIDGE = 0x8 91 | YOGHURT = 0x9 92 | EXPRESS = 0xa 93 | 94 | 95 | class CookerSubProgram(Enum): 96 | NONE = 0 97 | VEGETABLES = 1 98 | FISH = 2 99 | MEAT = 3 100 | 101 | 102 | @dataclass 103 | class KettleG200State: 104 | temperature: int = 0 105 | color_change_period: int = 0xf 106 | mode: KettleG200Mode = KettleG200Mode.BOIL 107 | target_temperature: int = 0 108 | sound: bool = True 109 | is_blocked: bool = False 110 | state: KettleRunState = KettleRunState.OFF 111 | boil_time: int = 0 112 | error: int = 0 113 | 114 | FORMAT = '<6BH2BH4B' 115 | 116 | @classmethod 117 | def from_bytes(cls, response): 118 | # 00 00 00 00 01 16 0f 00 00 00 00 00 00 80 00 00 - wait 119 | # 00 00 00 00 01 14 0f 00 02 00 00 00 00 80 00 00 - boil 120 | # 01 00 28 00 01 19 0f 00 00 00 00 00 00 80 00 00 - 40º keep 121 | ( 122 | mode, # 0 123 | submode, # 1 124 | target_temp, # 2 125 | is_blocked, # 3 126 | sound, # 4 127 | current_temp, # 5 128 | color_change_period, # 6-7 129 | state, # 8 130 | _, # 9 131 | ionization, # 10,11 # for air purifier 132 | _, # 12, 133 | boil_time_relative, # 13 134 | _, # 14 135 | error, # 15 136 | ) = struct.unpack(cls.FORMAT, response) 137 | return cls( 138 | mode=KettleG200Mode(mode), 139 | target_temperature=target_temp, 140 | sound=sound, 141 | temperature=current_temp, 142 | state=KettleRunState(state), 143 | boil_time=boil_time_relative - BOIL_TIME_RELATIVE_DEFAULT, 144 | color_change_period=color_change_period, 145 | error=error, 146 | ) 147 | 148 | def to_bytes(self): 149 | return struct.pack( 150 | self.FORMAT, 151 | self.mode.value, 152 | 0, 153 | self.target_temperature, 154 | 1 if self.is_blocked else 0, 155 | self.sound, 156 | self.temperature, 157 | self.color_change_period, 158 | self.state.value, 159 | 0, 160 | 0, 161 | 0, 162 | self.boil_time + BOIL_TIME_RELATIVE_DEFAULT, 163 | 0, 164 | 0, # don't send error 165 | ) 166 | 167 | 168 | @dataclass 169 | class CookerState: 170 | program: CookerM200Program = CookerM200Program.RICE 171 | subprogram: CookerSubProgram = CookerSubProgram.NONE 172 | target_temperature: int = 0 173 | program_minutes: int = 0 174 | timer_minutes: int = 0 175 | after_cooking_mode: CookerAfterCookMode = \ 176 | CookerAfterCookMode.HEAT_AFTER_COOK 177 | state: CookerRunState = CookerRunState.OFF 178 | sound: bool = True 179 | locked: bool = False 180 | 181 | SET_FORMAT = '<8B' 182 | FORMAT = f'{SET_FORMAT}6BH' 183 | 184 | @classmethod 185 | def from_bytes(cls, response): 186 | # 00 00 00 00 00 00 00 00 00 00 01 00 00 00 00 00 - wait with sound 187 | # 00 00 64 00 00 00 00 00 00 01 01 00 00 00 00 00 - locked 188 | # 00 00 96 00 0f 00 0f 01 00 00 00 00 00 00 00 00 - 150º 15 minutes 189 | 190 | ( 191 | program, # 0 192 | subprogram, # 1 193 | target_temp, # 2 194 | program_hours, # 3 195 | program_minutes, # 4 196 | timer_hours, # 5 197 | timer_minutes, # 6 198 | after_cooking_mode, # 7 199 | state, # 8 200 | locked, # 9 201 | sound, # 10 202 | _, # 11 203 | _, # 12, 204 | _, # 13 205 | error, # 14,15 206 | ) = struct.unpack(cls.FORMAT, response) 207 | return cls( 208 | program=CookerM200Program(program), 209 | subprogram=CookerSubProgram(subprogram), 210 | target_temperature=target_temp, 211 | after_cooking_mode=CookerAfterCookMode(after_cooking_mode), 212 | state=CookerRunState(state), 213 | program_minutes=(program_minutes + program_hours * 60), 214 | timer_minutes=(timer_minutes + timer_hours * 60), 215 | sound=bool(sound), 216 | locked=bool(locked), 217 | ) 218 | 219 | def to_bytes(self): 220 | return struct.pack( 221 | self.SET_FORMAT, 222 | self.program.value, 223 | self.subprogram.value, 224 | self.target_temperature, 225 | self.program_minutes // 60, 226 | self.program_minutes % 60, 227 | self.timer_minutes // 60, 228 | self.timer_minutes % 60, 229 | self.after_cooking_mode.value, 230 | ) 231 | 232 | 233 | _COOKER_M200_PREDEFINED_PROGRAMS_VALUES = [ 234 | # program, subprogram, temperature, hours, minutes, dhours, dminutes, heat 235 | [0x00, 0x00, 0x96, 0x00, 0x0f, 0x00, 0x00, 0x01], 236 | [0x01, 0x00, 0x64, 0x00, 0x19, 0x00, 0x00, 0x01], 237 | [0x02, 0x00, 0x64, 0x00, 0x1e, 0x00, 0x00, 0x01], 238 | [0x03, 0x00, 0x6e, 0x01, 0x00, 0x00, 0x00, 0x01], 239 | [0x04, 0x00, 0x64, 0x00, 0x19, 0x00, 0x00, 0x01], 240 | [0x05, 0x00, 0x8c, 0x01, 0x00, 0x00, 0x00, 0x01], 241 | [0x06, 0x00, 0x64, 0x01, 0x00, 0x00, 0x00, 0x01], 242 | [0x07, 0x00, 0x64, 0x01, 0x00, 0x00, 0x00, 0x01], 243 | [0x08, 0x00, 0x64, 0x00, 0x1e, 0x00, 0x00, 0x01], 244 | [0x09, 0x00, 0x28, 0x08, 0x00, 0x00, 0x00, 0x00], 245 | [0x0a, 0x00, 0x64, 0x00, 0x1e, 0x00, 0x00, 0x00], 246 | ] 247 | 248 | 249 | COOKER_PREDEFINED_PROGRAMS = { 250 | CookerM200Program(v[0]).name.lower(): CookerState( 251 | program=CookerM200Program(v[0]), 252 | subprogram=CookerSubProgram(v[1]), 253 | target_temperature=v[2], 254 | program_minutes=v[3] * 60 + v[4], 255 | timer_minutes=v[5] * 60 + v[6], 256 | after_cooking_mode=CookerAfterCookMode(v[7]), 257 | ) 258 | for v in _COOKER_M200_PREDEFINED_PROGRAMS_VALUES 259 | } 260 | 261 | 262 | class RedmondCommand(BaseCommand): 263 | def __init__(self, cmd, payload, *args, **kwargs): 264 | super().__init__(cmd, *args, **kwargs) 265 | self.payload = payload 266 | 267 | 268 | class RedmondBaseProtocol(SendAndWaitReplyMixin, BLEQueueMixin, BaseDevice, 269 | abc.ABC): 270 | MAGIC_START = 0x55 271 | MAGIC_END = 0xaa 272 | 273 | RX_CHAR: uuid.UUID = None # type: ignore 274 | TX_CHAR: uuid.UUID = None # type: ignore 275 | 276 | def __init__(self, *args, **kwargs) -> None: 277 | super().__init__(*args, **kwargs) 278 | self._cmd_counter = 0 279 | 280 | def _get_command(self, cmd: int, payload: bytes): 281 | container = struct.pack( 282 | '<4B', 283 | self.MAGIC_START, 284 | self._cmd_counter, 285 | cmd, 286 | self.MAGIC_END, 287 | ) 288 | self._cmd_counter += 1 289 | if self._cmd_counter > 100: 290 | self._cmd_counter = 0 291 | return bytearray(b'%b%b%b' % (container[:3], payload, container[3:])) 292 | 293 | async def send_command(self, cmd: Command, payload: bytes = b'', 294 | wait_reply=True, timeout=25): 295 | command = RedmondCommand( 296 | cmd, 297 | payload=payload, 298 | wait_reply=wait_reply, 299 | timeout=timeout, 300 | ) 301 | await self.cmd_queue.put(command) 302 | return await aio.wait_for(command.answer, timeout) 303 | 304 | async def process_command(self, command: RedmondCommand): 305 | cmd = self._get_command(command.cmd.value, command.payload) 306 | _LOGGER.debug( 307 | f'... send cmd {command.cmd.value:04x} [' 308 | f'{format_binary(command.payload, delimiter="")}] ' 309 | f'{format_binary(cmd)}', 310 | ) 311 | self.clear_ble_queue() 312 | cmd_resp = await aio.wait_for( 313 | self.client.write_gatt_char(self.TX_CHAR, cmd, True), 314 | timeout=command.timeout, 315 | ) 316 | if not command.wait_reply: 317 | if command.answer.cancelled(): 318 | return 319 | command.answer.set_result(cmd_resp) 320 | return 321 | 322 | ble_notification = await self.ble_get_notification(command.timeout) 323 | 324 | # extract payload from container 325 | cmd_resp = bytes(ble_notification[1][3:-1]) 326 | if command.answer.cancelled(): 327 | return 328 | command.answer.set_result(cmd_resp) 329 | 330 | async def protocol_start(self): 331 | # we assume that every time protocol starts it uses new blank 332 | # BleakClient to avoid multiple char notifications on every restart 333 | # bug ? 334 | 335 | assert self.RX_CHAR and self.TX_CHAR 336 | # if not self.notification_started: 337 | assert self.client.is_connected 338 | assert not self._cmd_queue_task.done() 339 | _LOGGER.debug(f'Enable BLE notifications from [{self.client.address}]') 340 | await self.client.write_gatt_char( 341 | self.TX_CHAR, 342 | bytearray(0x01.to_bytes(2, byteorder="little")), 343 | True, 344 | ) 345 | await self.client.start_notify( 346 | self.RX_CHAR, 347 | self.notification_callback, 348 | ) 349 | 350 | async def protocol_stop(self): 351 | # NB: not used for now as we don't disconnect from our side 352 | await self.client.stop_notify(self.RX_CHAR) 353 | 354 | async def close(self): 355 | self.clear_cmd_queue() 356 | await super().close() 357 | 358 | @staticmethod 359 | def _check_success(response, 360 | error_msg="Command was not completed successfully"): 361 | success = response and response[0] 362 | if not success: 363 | raise RedmondError(error_msg) 364 | 365 | @staticmethod 366 | def _check_zero_response(response, 367 | error_msg="Command was not completed successfully", 368 | ): 369 | response = response and response[0] 370 | if response != 0: 371 | raise RedmondError(error_msg) 372 | 373 | 374 | class RedmondCommonProtocol(RedmondBaseProtocol, abc.ABC): 375 | """ Shared methods between different devices """ 376 | async def login(self, key): 377 | _LOGGER.debug('logging in...') 378 | resp = await self.send_command(Command.AUTH, key, True) 379 | self._check_success(resp, "Not logged in") 380 | 381 | async def get_version(self): 382 | _LOGGER.debug('fetching version...') 383 | resp = await self.send_command(Command.VERSION, b'', True) 384 | version = tuple(resp) 385 | _LOGGER.debug(f'version: {version}') 386 | return version 387 | 388 | async def run(self): 389 | _LOGGER.debug('Run mode') 390 | resp = await self.send_command(Command.RUN_CURRENT_MODE) 391 | self._check_success(resp) 392 | 393 | async def stop(self): 394 | _LOGGER.debug('Stop mode') 395 | resp = await self.send_command(Command.STOP_CURRENT_MODE) 396 | self._check_success(resp) 397 | 398 | 399 | class RedmondKettle200Protocol(RedmondCommonProtocol, abc.ABC): 400 | async def set_time(self, ts=None): 401 | if ts is None: 402 | ts = time.time() 403 | ts = int(ts) 404 | offset = time.timezone \ 405 | if (time.localtime().tm_isdst == 0) else time.altzone 406 | _LOGGER.debug(f'Setting time ts={ts} offset={offset}') 407 | resp = await self.send_command( 408 | Command.SET_TIME, 409 | struct.pack(' None: 15 | if len(raw_data) < 24: 16 | raise ValueError( 17 | "Data must be at least 24 bytes long for data format 5", 18 | ) 19 | self.data = struct.unpack(">BhHHhhhHBH6B", raw_data) 20 | 21 | @property 22 | def temperature_celsius(self) -> ty.Optional[float]: 23 | if self.data[1] == -32768: 24 | return None 25 | return round(self.data[1] / 200.0, 2) 26 | 27 | @property 28 | def humidity_percentage(self) -> ty.Optional[float]: 29 | if self.data[2] == 65535: 30 | return None 31 | return round(self.data[2] / 400, 2) 32 | 33 | @property 34 | def pressure_hpa(self) -> ty.Optional[float]: 35 | if self.data[3] == 0xFFFF: 36 | return None 37 | 38 | return round((self.data[3] + 50000) / 100, 2) 39 | 40 | @property 41 | def acceleration_vector_mg(self) \ 42 | -> ty.Union[ 43 | ty.Sequence[int, int, int], 44 | ty.Sequence[None, None, None], 45 | ]: 46 | ax = self.data[4] 47 | ay = self.data[5] 48 | az = self.data[6] 49 | if ax == -32768 or ay == -32768 or az == -32768: 50 | return None, None, None 51 | 52 | return ax, ay, az 53 | 54 | @property 55 | def acceleration_total_mg(self) -> ty.Optional[float]: 56 | ax, ay, az = self.acceleration_vector_mg 57 | if ax is None or ay is None or az is None: 58 | return None 59 | return math.sqrt(ax * ax + ay * ay + az * az) 60 | 61 | @property 62 | def battery_voltage_mv(self) -> ty.Optional[int]: 63 | voltage = self.data[7] >> 5 64 | if voltage == 0b11111111111: 65 | return None 66 | 67 | return voltage + 1600 68 | 69 | @property 70 | def tx_power_dbm(self) -> ty.Optional[int]: 71 | tx_power = self.data[7] & 0x001F 72 | if tx_power == 0b11111: 73 | return None 74 | 75 | return -40 + (tx_power * 2) 76 | 77 | @property 78 | def movement_counter(self) -> int: 79 | return self.data[8] 80 | 81 | @property 82 | def measurement_sequence_number(self) -> int: 83 | return self.data[9] 84 | 85 | @property 86 | def mac(self) -> str: 87 | return ":".join(f"{x:02X}" for x in self.data[10:]) 88 | -------------------------------------------------------------------------------- /ble2mqtt/protocols/soma.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import logging 3 | import typing as ty 4 | import uuid 5 | from enum import Enum 6 | 7 | from ble2mqtt.devices.base import BaseDevice 8 | 9 | from ..utils import format_binary 10 | from .base import BLEQueueMixin 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | class MotorTriggerCommandCodes(Enum): 16 | ADD = 0x13 17 | REMOVE = 0x23 18 | READ = 0x33 19 | EDIT = 0x43 20 | CLEAR_ALL = 0x63 21 | 22 | 23 | class MotorCommandCodes(Enum): 24 | STOP = 0x00 25 | STOP_AT_NEXT_STEP = 0x01 26 | STOP_AT_NEXT_STEP_STATE = 0x03 27 | STEP_UP = 0x68 28 | UP = 0x69 29 | STEP_DOWN = 0x86 30 | DOWN = 0x96 31 | LOW_BATTERY = 0xff 32 | 33 | 34 | class ConfigCommandCodes(Enum): 35 | MOTOR_SPEED = 1 36 | MOTOR_DIRECTION = 2 37 | MOTOR_SPEED_TRIGGER = 3 38 | PID = 4 39 | GEO_POSITION = 5 40 | LOCAL_TIME_OFFSET = 6 41 | MOTOR_ACCELERATION = 7 42 | MOTOR_DECELERATION = 8 43 | MOTOR_USTALL_ACCELERATION = 9 44 | INCREASE_ENCODER_BY2 = 10 45 | INCREASE_ENCODER_BY4 = 11 46 | BOOT_SEQ = 12 47 | RESET_REASON = 13 48 | STOP_REASON = 14 49 | POF_COUNT = 15 50 | SLIP_LENGTH = 16 51 | ENC_MAX = 17 52 | ENC_CUR = 18 53 | SLIP_INTERVAL = 19 54 | POSITION_MOVE_TOTAL = 20 55 | MOTOR_MOVE_TOTAL = 21 56 | IN_CALIBRATION_MODE = 22 57 | SUNRISE_SUNSET = 23 58 | MOTOR_CURRENT = 24 59 | QUERY = 255 60 | 61 | 62 | class SomaProtocol(BLEQueueMixin, BaseDevice, abc.ABC): 63 | POSITION_CHAR: uuid.UUID 64 | MOTOR_CHAR: uuid.UUID 65 | SET_POSITION_CHAR: uuid.UUID 66 | BATTERY_CHAR: uuid.UUID 67 | CHARGING_CHAR: uuid.UUID 68 | CONFIG_CHAR: uuid.UUID 69 | 70 | LIGHT_COEFF_TO_LUX = 10 71 | 72 | def notification_callback(self, sender_handle: int, data: bytearray): 73 | _LOGGER.debug( 74 | f'{self} notification: {sender_handle}: {format_binary(data)}') 75 | if sender_handle == 71: 76 | # CONFIG_CHAR 77 | if data[0] == ConfigCommandCodes.QUERY.value: 78 | return super().notification_callback(sender_handle, data) 79 | if data[0] == ConfigCommandCodes.MOTOR_SPEED.value: 80 | # values are 0x0, 0x3, 0x69, 0x96 81 | self._handle_motor_run_state(MotorCommandCodes(data[2])) 82 | elif sender_handle == 32: 83 | # POSITION_CHAR 84 | self._handle_position(self._convert_position(data[0])) 85 | elif sender_handle == 66: 86 | # CHARGING_CHAR 87 | self._handle_charging(**self._parse_charge_response(data)) 88 | 89 | @staticmethod 90 | def _convert_position(value): 91 | return 100 - value 92 | 93 | @abc.abstractmethod 94 | def _handle_position(self, value): 95 | pass 96 | 97 | @abc.abstractmethod 98 | def _handle_charging(self, *, charging_level, panel_level): 99 | pass 100 | 101 | @abc.abstractmethod 102 | def _handle_motor_run_state(self, run_state: MotorCommandCodes): 103 | pass 104 | 105 | async def _get_position(self): 106 | response = await self.client.read_gatt_char(self.POSITION_CHAR) 107 | _LOGGER.debug(f'{self} _get_position: [{format_binary(response)}]') 108 | return self._convert_position(response[0]) 109 | 110 | async def _get_target_position(self): 111 | response = await self.client.read_gatt_char(self.SET_POSITION_CHAR) 112 | _LOGGER.debug( 113 | f'{self} _get_target_position: [{format_binary(response)}]', 114 | ) 115 | return self._convert_position(response[0]) 116 | 117 | async def _get_battery(self): 118 | response = await self.client.read_gatt_char(self.BATTERY_CHAR) 119 | _LOGGER.debug( 120 | f'{self} _get_battery: [{format_binary(response)}]', 121 | ) 122 | return int(min(100.0, response[0] / 75 * 100)) 123 | 124 | async def _get_light_and_panel(self): 125 | response = await self.client.read_gatt_char(self.CHARGING_CHAR) 126 | _LOGGER.debug( 127 | f'{self} _get_light_and_panel: [{format_binary(response)}]', 128 | ) 129 | return self._parse_charge_response(response) 130 | 131 | async def _set_position(self, value): 132 | value = self._convert_position(value) 133 | _LOGGER.debug(f'{self} _set_position: {value}') 134 | await self.client.write_gatt_char( 135 | self.SET_POSITION_CHAR, 136 | bytes([value]), 137 | response=False, 138 | ) 139 | 140 | def _parse_config_response(self, data): 141 | result = {} 142 | if data[0] != ConfigCommandCodes.QUERY.value: 143 | return result 144 | 145 | offset = 2 146 | while offset < len(data): 147 | cmd = data[offset] 148 | length = data[offset + 1] 149 | offset += 2 150 | value = data[offset:offset + length] 151 | if length == 1: 152 | value = value[0] 153 | result[ConfigCommandCodes(cmd)] = value 154 | offset += length 155 | return result 156 | 157 | def _parse_charge_response(self, data): 158 | return { 159 | 'charging_level': ( 160 | int.from_bytes(data[:2], byteorder='little') * 161 | self.LIGHT_COEFF_TO_LUX 162 | ), 163 | 'panel_level': int.from_bytes(data[2:4], byteorder='little'), 164 | } 165 | 166 | async def _get_motor_speed(self) -> ty.Optional[int]: 167 | cmd = bytes([ 168 | ConfigCommandCodes.QUERY.value, 169 | 0x01, # length 170 | ConfigCommandCodes.MOTOR_SPEED.value, 171 | ]) 172 | self.clear_ble_queue() 173 | await self.client.write_gatt_char( 174 | self.CONFIG_CHAR, 175 | cmd, 176 | response=True, 177 | ) 178 | ble_notification = await self.ble_get_notification(timeout=10) 179 | parsed = self._parse_config_response(ble_notification[1]) 180 | _LOGGER.debug(f'_motor_speed parsed {parsed}') 181 | return parsed.get(ConfigCommandCodes.MOTOR_SPEED, None) 182 | 183 | async def _set_motor_speed(self, value): 184 | value = value & 0xff 185 | _LOGGER.debug(f'{self} _set_motor_speed: {value}') 186 | cmd = bytes([ConfigCommandCodes.MOTOR_SPEED.value, 0x01, value]) 187 | _LOGGER.debug(f'{self} _set_motor_speed cmd [{format_binary(cmd)}]') 188 | await self.client.write_gatt_char( 189 | self.CONFIG_CHAR, 190 | cmd, 191 | response=True, 192 | ) 193 | 194 | async def _stop(self): 195 | resp = await self.client.write_gatt_char( 196 | self.MOTOR_CHAR, 197 | bytes([MotorCommandCodes.STOP.value]), 198 | response=True, 199 | ) 200 | _LOGGER.debug(f'{self} _stop: {resp}') 201 | 202 | # async def _open(self): 203 | # await self.client.write_gatt_char( 204 | # self.MOTOR_CHAR, 205 | # bytes([MotorCommandCodes.UP.value]), 206 | # response=True, 207 | # ) 208 | # 209 | # async def _close(self): 210 | # await self.client.write_gatt_char( 211 | # self.MOTOR_CHAR, 212 | # bytes([MotorCommandCodes.DOWN.value]), 213 | # response=False, 214 | # ) 215 | -------------------------------------------------------------------------------- /ble2mqtt/protocols/wp6003.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import asyncio as aio 3 | import datetime 4 | import logging 5 | import uuid 6 | 7 | from ble2mqtt.devices.base import BaseDevice 8 | from ble2mqtt.protocols.base import (BaseCommand, BLEQueueMixin, 9 | SendAndWaitReplyMixin) 10 | from ble2mqtt.utils import format_binary 11 | 12 | _LOGGER = logging.getLogger(__name__) 13 | 14 | 15 | CMD_SET_TIME = 0xaa 16 | CMD_SET_NOTIFY_PERIOD = 0xae 17 | CMD_READ_VALUE = 0xab 18 | CMD_REQUEST_CALIBRATION = 0xad 19 | CMD_RESET = 0xee 20 | 21 | 22 | class WP6003Command(BaseCommand): 23 | pass 24 | 25 | 26 | class WP6003Protocol(SendAndWaitReplyMixin, BLEQueueMixin, BaseDevice, 27 | abc.ABC): 28 | RX_CHAR: uuid.UUID = None # type: ignore 29 | TX_CHAR: uuid.UUID = None # type: ignore 30 | 31 | async def protocol_start(self): 32 | assert self.RX_CHAR 33 | await self.client.start_notify( 34 | self.RX_CHAR, 35 | self.notification_callback, 36 | ) 37 | 38 | async def send_reset(self): 39 | await self.send_command(bytes([CMD_RESET]), True, timeout=3) 40 | 41 | async def write_time(self): 42 | now = datetime.datetime.utcnow() 43 | set_time_cmd = bytes([ 44 | CMD_SET_TIME, 45 | now.year - 2000, 46 | now.month, 47 | now.day, 48 | now.hour, 49 | now.minute, 50 | now.second, 51 | ]) 52 | # consider process response here 53 | return await self.send_command(set_time_cmd, True, timeout=3) 54 | 55 | async def read_value(self) -> bytes: 56 | return await self.send_command( 57 | bytes([CMD_READ_VALUE]), 58 | wait_reply=True, 59 | timeout=3, 60 | ) 61 | 62 | async def send_command(self, cmd: bytes = b'', 63 | wait_reply=False, timeout=10): 64 | command = WP6003Command(cmd, wait_reply=wait_reply, timeout=timeout) 65 | self.clear_ble_queue() 66 | await self.cmd_queue.put(command) 67 | return await aio.wait_for(command.answer, timeout) 68 | 69 | async def process_command(self, command: WP6003Command): 70 | _LOGGER.debug(f'... send cmd {format_binary(command.cmd)}') 71 | self.clear_ble_queue() 72 | cmd_resp = await aio.wait_for( 73 | self.client.write_gatt_char(self.TX_CHAR, command.cmd), 74 | timeout=command.timeout, 75 | ) 76 | if not command.wait_reply: 77 | if command.answer.cancelled(): 78 | return 79 | command.answer.set_result(cmd_resp) 80 | return 81 | 82 | ble_notification = await self.ble_get_notification(command.timeout) 83 | 84 | # extract payload from container 85 | cmd_resp = bytes(ble_notification[1]) 86 | if command.answer.cancelled(): 87 | return 88 | command.answer.set_result(cmd_resp) 89 | -------------------------------------------------------------------------------- /ble2mqtt/protocols/xiaomi.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import asyncio as aio 3 | import logging 4 | import struct 5 | import uuid 6 | 7 | from ..compat import get_loop_param 8 | from ..devices.base import Sensor, SubscribeAndSetDataMixin 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | 12 | 13 | # Xiaomi Humidity/Temperature sensors 14 | 15 | class XiaomiPoller(SubscribeAndSetDataMixin, Sensor, abc.ABC): 16 | DATA_CHAR: uuid.UUID = None # type: ignore 17 | BATTERY_CHAR: uuid.UUID = None # type: ignore 18 | MANUFACTURER = 'Xiaomi' 19 | 20 | def __init__(self, *args, loop, **kwargs): 21 | super().__init__(*args, loop=loop, **kwargs) 22 | self._stack = aio.LifoQueue(**get_loop_param(loop)) 23 | 24 | def process_data(self, data): 25 | self._loop.call_soon_threadsafe(self._stack.put_nowait, data) 26 | 27 | async def read_and_send_data(self, publish_topic): 28 | raise NotImplementedError() 29 | 30 | async def handle_active(self, publish_topic, send_config, *args, **kwargs): 31 | _LOGGER.debug(f'Wait {self} for connecting...') 32 | sec_to_wait_connection = 0 33 | while True: 34 | if not self.client.is_connected: 35 | if sec_to_wait_connection >= 30: 36 | raise TimeoutError( 37 | f'{self} not connected for 30 sec in handle()', 38 | ) 39 | sec_to_wait_connection += self.NOT_READY_SLEEP_INTERVAL 40 | await aio.sleep(self.NOT_READY_SLEEP_INTERVAL) 41 | continue 42 | try: 43 | _LOGGER.debug(f'{self} connected!') 44 | # in case of bluetooth error populating queue 45 | # could stop and will wait for self._stack.get() forever 46 | await self.update_device_data(send_config) 47 | await aio.wait_for( 48 | self.read_and_send_data(publish_topic), 49 | timeout=15, 50 | ) 51 | except ValueError as e: 52 | _LOGGER.error(f'[{self}] Cannot read values {str(e)}') 53 | else: 54 | await self.close() 55 | return 56 | await aio.sleep(1) 57 | 58 | 59 | # Xiaomi Kettles 60 | 61 | class XiaomiCipherMixin: 62 | # Picked from the https://github.com/drndos/mikettle/ 63 | @staticmethod 64 | def generate_random_token() -> bytes: 65 | return bytes([ # from component, maybe random is ok 66 | 0x01, 0x5C, 0xCB, 0xA8, 0x80, 0x0A, 0xBD, 0xC1, 0x2E, 0xB8, 67 | 0xED, 0x82, 68 | ]) 69 | # return os.urandom(12) 70 | 71 | @staticmethod 72 | def reverse_mac(mac) -> bytes: 73 | parts = mac.split(":") 74 | reversed_mac = bytearray() 75 | length = len(parts) 76 | for i in range(1, length + 1): 77 | reversed_mac.extend(bytearray.fromhex(parts[length - i])) 78 | return reversed_mac 79 | 80 | @staticmethod 81 | def mix_a(mac, product_id) -> bytes: 82 | return bytes([ 83 | mac[0], mac[2], mac[5], (product_id & 0xff), (product_id & 0xff), 84 | mac[4], mac[5], mac[1], 85 | ]) 86 | 87 | @staticmethod 88 | def mix_b(mac, product_id) -> bytes: 89 | return bytes([ 90 | mac[0], mac[2], mac[5], ((product_id >> 8) & 0xff), mac[4], mac[0], 91 | mac[5], (product_id & 0xff), 92 | ]) 93 | 94 | @staticmethod 95 | def _cipher_init(key) -> bytes: 96 | perm = bytearray() 97 | for i in range(0, 256): 98 | perm.extend(bytes([i & 0xff])) 99 | keyLen = len(key) 100 | j = 0 101 | for i in range(0, 256): 102 | j += perm[i] + key[i % keyLen] 103 | j = j & 0xff 104 | perm[i], perm[j] = perm[j], perm[i] 105 | return perm 106 | 107 | @staticmethod 108 | def _cipher_crypt(input, perm) -> bytes: 109 | index1 = 0 110 | index2 = 0 111 | output = bytearray() 112 | for i in range(0, len(input)): 113 | index1 = index1 + 1 114 | index1 = index1 & 0xff 115 | index2 += perm[index1] 116 | index2 = index2 & 0xff 117 | perm[index1], perm[index2] = perm[index2], perm[index1] 118 | idx = perm[index1] + perm[index2] 119 | idx = idx & 0xff 120 | output_byte = input[i] ^ perm[idx] 121 | output.extend(bytes([output_byte & 0xff])) 122 | 123 | return output 124 | 125 | @classmethod 126 | def cipher(cls, key, input) -> bytes: 127 | perm = cls._cipher_init(key) 128 | return cls._cipher_crypt(input, perm) 129 | 130 | 131 | # region xiaomi advert parsers from 0xfe95 132 | 133 | # this part is partly taken from ble_monitor component for Home Assistant 134 | 135 | # Structured objects for data conversions 136 | TH_STRUCT = struct.Struct(" dict: 323 | frctrl = int.from_bytes(adv_data[:2], byteorder='little') 324 | 325 | # frctrl_mesh = (frctrl >> 7) & 1 # mesh device 326 | # frctrl_version = frctrl >> 12 # version 327 | # frctrl_auth_mode = (frctrl >> 10) & 3 328 | # frctrl_solicited = (frctrl >> 9) & 1 329 | # frctrl_registered = (frctrl >> 8) & 1 330 | # frctrl_object_include = (frctrl >> 6) & 1 331 | frctrl_capability_include = (frctrl >> 5) & 1 332 | frctrl_mac_include = (frctrl >> 4) & 1 # check for MAC address in data 333 | # frctrl_is_encrypted = (frctrl >> 3) & 1 # check for encryption being used 334 | # frctrl_request_timing = frctrl & 1 # old version 335 | 336 | counter = 5 337 | if frctrl_mac_include: 338 | counter += 6 339 | # check for capability byte present 340 | if frctrl_capability_include: 341 | counter += 1 342 | # capability_io = adv_data[counter - 1] 343 | 344 | payload = adv_data[counter:] 345 | result = {} 346 | if payload: 347 | payload_start = 0 348 | payload_length = len(payload) 349 | while payload_length >= payload_start + 3: 350 | obj_typecode = \ 351 | payload[payload_start] + (payload[payload_start + 1] << 8) 352 | obj_length = payload[payload_start + 2] 353 | next_start = payload_start + 3 + obj_length 354 | if payload_length < next_start: 355 | _LOGGER.debug( 356 | "Invalid payload data length, payload: %s", payload.hex(), 357 | ) 358 | break 359 | object = payload[payload_start + 3:next_start] 360 | if obj_length != 0: 361 | resfunc = xiaomi_dataobject_dict.get(obj_typecode, None) 362 | if resfunc: 363 | # if hex(obj_typecode) in ["0x1001", "0xf"]: 364 | # result.update(resfunc(object, device_type)) 365 | # else: 366 | result.update(resfunc(object)) 367 | else: 368 | # if self.report_unknown == "Xiaomi": 369 | _LOGGER.info( 370 | "UNKNOWN dataobject in payload! Adv: %s", 371 | adv_data.hex(), 372 | ) 373 | payload_start = next_start 374 | 375 | return result 376 | 377 | # endregion 378 | -------------------------------------------------------------------------------- /ble2mqtt/tasks.py: -------------------------------------------------------------------------------- 1 | import asyncio as aio 2 | import logging 3 | import typing as ty 4 | 5 | _LOGGER = logging.getLogger(__name__) 6 | 7 | 8 | async def run_tasks_and_cancel_on_first_return(*tasks: aio.Future, 9 | return_when=aio.FIRST_COMPLETED, 10 | ignore_futures=(), 11 | ) -> ty.Sequence[aio.Future]: 12 | async def cancel_tasks(_tasks) -> ty.List[aio.Task]: 13 | # cancel first, then await. Because other tasks can raise exceptions 14 | # while switching tasks 15 | canceled = [] 16 | for t in _tasks: 17 | if t in ignore_futures: 18 | continue 19 | if not t.done(): 20 | t.cancel() 21 | canceled.append(t) 22 | tasks_raise_exceptions = [] 23 | for t in canceled: 24 | try: 25 | await t 26 | except aio.CancelledError: 27 | pass 28 | except Exception: 29 | _LOGGER.exception( 30 | f'Unexpected exception while cancelling tasks! {t}', 31 | ) 32 | tasks_raise_exceptions.append(t) 33 | return tasks_raise_exceptions 34 | 35 | assert all(isinstance(t, aio.Future) for t in tasks) 36 | try: 37 | # NB: pending tasks can still raise exception or finish 38 | # while tasks are switching 39 | done, pending = await aio.wait(tasks, return_when=return_when) 40 | except aio.CancelledError: 41 | await cancel_tasks(tasks) 42 | # it could happen that tasks raised exception and canceling wait task 43 | # abandons tasks with exception 44 | for t in tasks: 45 | if not t.done() or t.cancelled(): 46 | continue 47 | try: 48 | t.result() 49 | # no CancelledError expected 50 | except Exception: 51 | _LOGGER.exception( 52 | f'Task raises exception while cancelling parent coroutine ' 53 | f'that waits for it {t}') 54 | raise 55 | 56 | # while switching tasks for await other pending tasks can raise an exception 57 | # we need to append more tasks to the result if so 58 | await cancel_tasks(pending) 59 | 60 | task_remains = [t for t in pending if not t.cancelled()] 61 | return [*done, *task_remains] 62 | 63 | 64 | async def handle_returned_tasks(*tasks: aio.Future): 65 | raised = [t for t in tasks if t.done() and t.exception()] 66 | returned_normally = set(tasks) - set(raised) 67 | 68 | results = [] 69 | 70 | if raised: 71 | task_for_raise = raised.pop() 72 | for t in raised: 73 | try: 74 | await t 75 | except aio.CancelledError: 76 | raise 77 | except Exception: 78 | _LOGGER.exception('Task raised an error') 79 | await task_for_raise 80 | for t in returned_normally: 81 | results.append(await t) 82 | return results 83 | -------------------------------------------------------------------------------- /ble2mqtt/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | MAX_RSSI = 0 4 | MIN_RSSI = -100 5 | 6 | 7 | def format_binary(data: bytes, delimiter=' '): 8 | return delimiter.join(format(x, '02x') for x in data) 9 | 10 | 11 | def cr2032_voltage_to_percent(mvolts: int): 12 | coeff = 0.8 # >2.9V counts as 100% = (2900 - 2100)/100 13 | return max(min(int(round((mvolts/1000 - 2.1)/coeff, 2) * 100), 100), 0) 14 | 15 | 16 | def cr2477_voltage_to_percent(mvolts: int): 17 | # Based on https://github.com/custom-components/ble_monitor/blob/18d447a8f/custom_components/ble_monitor/ble_parser/ruuvitag.py#L184-195 (MIT licensed) # noqa: E501 18 | if mvolts >= 3000: 19 | batt = 100 20 | elif mvolts >= 2600: 21 | batt = 60 + (mvolts - 2600) / 10 22 | elif mvolts >= 2500: 23 | batt = 40 + (mvolts - 2500) / 5 24 | elif mvolts >= 2450: 25 | batt = 20 + ((mvolts - 2450) * 2) / 5 26 | else: 27 | batt = 0 28 | return int(round(batt, 1)) 29 | 30 | 31 | def rssi_to_linkquality(rssi): 32 | return max(int(round(255 * (rssi - MIN_RSSI) / (MAX_RSSI - MIN_RSSI))), 0) 33 | 34 | 35 | # code from home assisstant 36 | 37 | def _match_max_scale(input_colors, output_colors): 38 | """Match the maximum value of the output to the input.""" 39 | max_in = max(input_colors) 40 | max_out = max(output_colors) 41 | if max_out == 0: 42 | factor = 0.0 43 | else: 44 | factor = max_in / max_out 45 | return tuple(int(round(i * factor)) for i in output_colors) 46 | 47 | 48 | def color_rgb_to_rgbw(r: int, g: int, b: int) -> Tuple[int, int, int, int]: 49 | """Convert an rgb color to an rgbw representation.""" 50 | # Calculate the white channel as the minimum of input rgb channels. 51 | # Subtract the white portion from the remaining rgb channels. 52 | w = min(r, g, b) 53 | rgbw = (r - w, g - w, b - w, w) 54 | 55 | # Match the output maximum value to the input. This ensures the full 56 | # channel range is used. 57 | return _match_max_scale((r, g, b), rgbw) # type: ignore 58 | 59 | 60 | def color_rgbw_to_rgb(r: int, g: int, b: int, w: int) -> Tuple[int, int, int]: 61 | """Convert an rgbw color to an rgb representation.""" 62 | # Add the white channel to the rgb channels. 63 | rgb = (r + w, g + w, b + w) 64 | 65 | # Match the output maximum value to the input. This ensures the 66 | # output doesn't overflow. 67 | return _match_max_scale((r, g, b, w), rgb) # type: ignore 68 | -------------------------------------------------------------------------------- /docker_entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -d "/var/run/dbus" ]; then 4 | ble2mqtt 5 | else 6 | service dbus start 7 | bluetoothd & 8 | ble2mqtt 9 | fi -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.7 3 | # ignore_missing_imports = true 4 | show_error_context = true 5 | strict_optional = true 6 | 7 | [mypy-bleak.*] 8 | ignore_missing_imports = True 9 | 10 | [mypy-aio_mqtt.*] 11 | ignore_missing_imports = True 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aio-mqtt-mod>=0.2.0 2 | bleak>=0.12.0 3 | pycryptodome 4 | -------------------------------------------------------------------------------- /selinux/README.md: -------------------------------------------------------------------------------- 1 | # SELinux Policy for bluetooth usage via docker 2 | 3 | 4 | ## Get SELinux logs 5 | 6 | If you are not sure that SELinux may be blocking your bluetooth access via the docker container, query the SELinux logs using `ausearch`: 7 | 8 | ``` 9 | ausearch -m AVC,USER_AVC,SELINUX_ERR,USER_SELINUX_ERR -ts recent 10 | ``` 11 | 12 | For example, this may be a possible output in case that SELinux is denying access to bluetooth: 13 | 14 | ``` 15 | type=USER_AVC msg=audit(1685511424.957:14363): pid=838 uid=81 auid=4294967295 ses=4294967295 subj=system_u:system_r:system_dbusd_t:s0-s0:c0.c1023 msg='avc: denied { send_msg } for scontext=system_u:system_r:bluetooth_t:s0 tcontext=system_u:system_r:spc_t:s0 tclass=dbus permissive=0 exe="/usr/bin/dbus-broker" sauid=81 hostname=? addr=? terminal=?' 16 | ``` 17 | 18 | This AVC denial error will make ble2mqtt to restart bluetooth over and over again (using `hciconfig` - deprecated), giving this (misleading) error: 19 | 20 | ``` 21 | Can't open HCI socket.: Address family not supported by protocol 22 | ``` 23 | 24 | 25 | ## Create SELinux policy 26 | 27 | This will create a modular policy file using a Type Enforcement (TE) file as input. 28 | 29 | Remember to run this script only as root user, in the host side (not in the container). 30 | 31 | ``` 32 | ./apply.docker-bluetoth-policy.sh 33 | ``` 34 | 35 | After applying the SELinux policy using `apply.docker-bluetoth-policy.sh`, make sure to restart `ble2mqtt` container. 36 | 37 | 38 | ### Recreate the SELinux policy 39 | 40 | If you want to create the TE file yourself, use `audit2allow`, which will create a policy file that will overcome your AVC errors. 41 | 42 | ``` 43 | ausearch -m AVC,USER_AVC,SELINUX_ERR,USER_SELINUX_ERR -ts recent | audit2allow -M policy 44 | ``` -------------------------------------------------------------------------------- /selinux/apply.docker-bluetoth-policy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | checkmodule -M -m -o docker_bluetooth.mod docker_bluetooth.te 4 | semodule_package -o docker_bluetooth.pp -m docker_bluetooth.mod 5 | semodule -i docker_bluetooth.pp 6 | 7 | # Cleanup 8 | rm -rf *.pp *.mod -------------------------------------------------------------------------------- /selinux/docker_bluetooth.te: -------------------------------------------------------------------------------- 1 | module docker_bluetooth 1.0; 2 | 3 | require { 4 | type spc_t; 5 | type bluetooth_t; 6 | class dbus send_msg; 7 | } 8 | 9 | #============= bluetooth_t ============== 10 | allow bluetooth_t spc_t:dbus send_msg; 11 | 12 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = setup.py venv/ tmp/ 3 | max-line-length = 80 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import find_packages, setup 4 | 5 | from ble2mqtt.__version__ import VERSION 6 | 7 | with open("README.md", "r") as fh: 8 | long_description = fh.read() 9 | 10 | setup( 11 | name='ble2mqtt', 12 | version=VERSION, 13 | description='BLE to MQTT bridge', 14 | long_description=long_description, 15 | long_description_content_type="text/markdown", 16 | author='Ivan Belokobylskiy', 17 | author_email='belokobylskij@gmail.com', 18 | url='https://github.com/devbis/ble2mqtt/', 19 | entry_points={ 20 | 'console_scripts': ['ble2mqtt=ble2mqtt.__main__:main'] 21 | }, 22 | packages=find_packages(include=['ble2mqtt', 'ble2mqtt.*']), 23 | install_requires=[ 24 | 'aio-mqtt-mod>=0.3.0', 25 | 'bleak>=0.12.0', 26 | ], 27 | extras_require={ 28 | 'full': ['pycryptodome'] 29 | }, 30 | classifiers=[ 31 | 'Programming Language :: Python :: 3.7', 32 | 'Programming Language :: Python :: 3.8', 33 | 'Programming Language :: Python :: 3.9', 34 | 'Programming Language :: Python :: 3.10', 35 | 'Programming Language :: Python :: 3.11', 36 | 'Programming Language :: Python :: 3 :: Only', 37 | 'Topic :: Utilities', 38 | ], 39 | ) 40 | --------------------------------------------------------------------------------