├── mi-scale ├── src │ ├── requirements.txt │ ├── wrapper.sh │ ├── body_scales.py │ ├── body_score.py │ ├── Xiaomi_Scale_Body_Metrics.py │ └── Xiaomi_Scale.py ├── dockerscripts │ ├── entrypoint.sh │ └── cmd.sh ├── .DS_Store ├── icon.png ├── logo.png ├── Dockerfile ├── config.json ├── CHANGELOG.md └── README.md ├── .DS_Store ├── repository.json ├── README.md ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── main.yml └── LICENSE /mi-scale/src/requirements.txt: -------------------------------------------------------------------------------- 1 | bluepy==1.3.0 2 | paho-mqtt==1.5.0 -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5high/hassio-addons/HEAD/.DS_Store -------------------------------------------------------------------------------- /mi-scale/dockerscripts/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | exec "$@" -------------------------------------------------------------------------------- /mi-scale/dockerscripts/cmd.sh: -------------------------------------------------------------------------------- 1 | set -e 2 | python3 -u /opt/miscale/Xiaomi_Scale.py 3 | -------------------------------------------------------------------------------- /mi-scale/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5high/hassio-addons/HEAD/mi-scale/.DS_Store -------------------------------------------------------------------------------- /mi-scale/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5high/hassio-addons/HEAD/mi-scale/icon.png -------------------------------------------------------------------------------- /mi-scale/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5high/hassio-addons/HEAD/mi-scale/logo.png -------------------------------------------------------------------------------- /repository.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Lolouk44 Add-Ons", 3 | "url": "https://github.com/5high/hassio-addons", 4 | "maintainer": "lolouk44" 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hassio-addons 2 | Xiaomi Mi Scale Add On for Home Assistant 3 | 4 | Fork 了大神的小米体脂秤addons,汉化一下。 5 | 作者主页:https://github.com/lolouk44/hassio-addons 6 | 7 | 我的主页:https://sumju.net 8 | YouTube: https://www.youtube.com/channel/UCf8MbF6J9xWf1m9guutozlw/ 9 | Bilibili: https://space.bilibili.com/441936678 10 | 11 | Docker Push命令: 12 | 13 | docker buildx build --platform linux/amd64,linux/386,linux/arm64,linux/arm/v7,linux/arm/v6 -t 5high/xiaomi-mi-scale-ha-add-on:0.1.16 . --push 14 | -------------------------------------------------------------------------------- /mi-scale/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-slim 2 | LABEL io.hass.version="0.1.16" io.hass.type="addon" io.hass.arch="armhf|aarch64|i386|amd64" 3 | WORKDIR /opt/miscale 4 | COPY src /opt/miscale 5 | 6 | RUN apt-get update && apt-get install -y \ 7 | bluez \ 8 | python-pip \ 9 | libglib2.0-dev && \ 10 | rm -rf /var/lib/apt/lists/* 11 | 12 | RUN pip install -r requirements.txt 13 | 14 | # Copy in docker scripts to root of container... 15 | COPY dockerscripts/ / 16 | 17 | RUN chmod +x /entrypoint.sh 18 | RUN chmod +x /cmd.sh 19 | ENTRYPOINT ["/entrypoint.sh"] 20 | CMD ["/cmd.sh"] -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Select Add-On (place a lowercase `x` for the relevant add-on)** 11 | - [ ] Xiaomi Mi Scale 12 | 13 | **Is your feature request related to a problem? Please describe.** 14 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 15 | 16 | **Describe the solution you'd like** 17 | A clear and concise description of what you want to happen. 18 | 19 | **Describe alternatives you've considered** 20 | A clear and concise description of any alternative solutions or features you've considered. 21 | 22 | **Additional context** 23 | Add any other context or screenshots about the feature request here. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Select Add-On (place a lowercase `x` for the relevant add-on)** 11 | - [ ] Xiaomi Mi Scale 12 | 13 | **Describe the bug** 14 | A clear and concise description of what the bug is. 15 | 16 | **To Reproduce** 17 | Steps to reproduce the behaviour, including error message if any. 18 | 19 | **Expected behaviour** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **Scale (please complete the following information):** 26 | - Name 27 | - Model # 28 | 29 | **Device running Home Assistant (please complete the following information):** 30 | - Device used to run the Script/Container [e.g. Raspberry Pi, NUC] 31 | - Bluetooth device used [e.g. Built-in, USB Dongle] 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 lolouk44 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | on: 3 | release: 4 | types: [published] 5 | 6 | jobs: 7 | push_to_registry: 8 | name: Push Docker image to Docker Hub 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Check out the repo 12 | uses: actions/checkout@v2 13 | 14 | - name: Set up Docker Buildx 15 | id: buildx 16 | uses: docker/setup-buildx-action@v1 17 | 18 | - name: Set up QEMU 19 | uses: docker/setup-qemu-action@v1 20 | 21 | - name: Login to DockerHub 22 | uses: docker/login-action@v1 23 | with: 24 | username: ${{ secrets.DOCKERHUB_USERNAME }} 25 | password: ${{ secrets.DOCKERHUB_TOKEN }} 26 | 27 | - name: Build image 28 | run: | 29 | docker buildx build --no-cache --push \ 30 | --tag lolouk44/xiaomi-mi-scale-ha-add-on:${{ github.event.release.tag_name }} \ 31 | --tag lolouk44/xiaomi-mi-scale-ha-add-on:latest \ 32 | --platform linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8 ./mi-scale/ 33 | 34 | - name: Image digest 35 | run: echo ${{ steps.docker_build.outputs.digest }} 36 | -------------------------------------------------------------------------------- /mi-scale/src/wrapper.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sleep 60 # Give the system time after a reboot to connect to WiFi before continuing 4 | export MISCALE_MAC=00:00:00:00:00:00 # Mac address of your scale 5 | export MQTT_PREFIX=miScale 6 | 7 | export USER1_GT=70 # If the weight is greater than this number, we'll assume that we're weighing User #1 8 | export USER1_SEX=male 9 | export USER1_NAME=Jo # Name of the user 10 | export USER1_HEIGHT=175 # Height (in cm) of the user 11 | export USER1_DOB=1990-01-01 # DOB (in yyyy-mm-dd format) 12 | 13 | export USER2_LT=35 # If the weight is less than this number, we'll assume that we're weighing User #2 14 | export USER2_SEX=female 15 | export USER2_NAME=Sarah # Name of the user 16 | export USER2_HEIGHT=95 # Height (in cm) of the user 17 | export USER2_DOB=1990-01-01 # DOB (in yyyy-mm-dd format) 18 | 19 | export USER3_SEX=female 20 | export USER3_NAME=Missy # Name of the user 21 | export USER3_HEIGHT=150 # Height (in cm) of the user 22 | export USER3_DOB=1990-01-01 # DOB (in yyyy-mm-dd format) 23 | 24 | MY_PWD="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 25 | python3 $MY_PWD/Xiaomi_Scale.py 26 | -------------------------------------------------------------------------------- /mi-scale/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Xiaomi Mi Scale", 3 | "version": "0.1.16", 4 | "slug": "xiaomi_mi_scale", 5 | "description": "Read weight measurements from Xiamomi scale via BLE", 6 | "url": "https://github.com/5high/xiaomi_mi_scale_ha_add_on", 7 | "image": "5high/xiaomi-mi-scale-ha-add-on", 8 | "arch": ["armhf", "armv7", "aarch64", "amd64", "i386"], 9 | "startup": "before", 10 | "boot": "auto", 11 | "panel_admin": false, 12 | "host_network": true, 13 | "privileged": ["NET_ADMIN", "SYS_ADMIN"], 14 | 15 | "options": { 16 | "HCI_DEV": "hci0", 17 | "MISCALE_MAC": "00:00:00:00:00:00", 18 | "MQTT_PREFIX": "miscale", 19 | "MQTT_HOST": "192.168.0.1", 20 | "MQTT_USERNAME": "user", 21 | "MQTT_PASSWORD": "passwd", 22 | "MQTT_PORT": 1883, 23 | "TIME_INTERVAL": 30, 24 | "MQTT_DISCOVERY": true, 25 | "MQTT_DISCOVERY_PREFIX": "homeassistant", 26 | 27 | "USER1_GT": 70, 28 | "USER1_SEX": "male", 29 | "USER1_NAME": "Jo", 30 | "USER1_HEIGHT": 175, 31 | "USER1_DOB": "1990-01-01", 32 | 33 | "USER2_LT": 35, 34 | "USER2_SEX": "female", 35 | "USER2_NAME": "Serena", 36 | "USER2_HEIGHT": 95, 37 | "USER2_DOB": "1990-01-01", 38 | 39 | "USER3_SEX": "female", 40 | "USER3_NAME": "Missy", 41 | "USER3_HEIGHT": 150, 42 | "USER3_DOB": "1990-01-01" 43 | 44 | }, 45 | "schema": { 46 | "HCI_DEV": "str?", 47 | "MISCALE_MAC": "str", 48 | "MQTT_PREFIX": "str?", 49 | "MQTT_HOST": "str", 50 | "MQTT_USERNAME": "str?", 51 | "MQTT_PASSWORD": "str?", 52 | "MQTT_PORT": "int?", 53 | "TIME_INTERVAL": "int?", 54 | "MQTT_DISCOVERY": "bool?", 55 | "MQTT_DISCOVERY_PREFIX": "str?", 56 | 57 | "USER1_GT": "int", 58 | "USER1_SEX": "str", 59 | "USER1_NAME": "str", 60 | "USER1_HEIGHT": "int", 61 | "USER1_DOB": "str", 62 | 63 | "USER2_LT": "int?", 64 | "USER2_SEX": "str?", 65 | "USER2_NAME": "str?", 66 | "USER2_HEIGHT": "int?", 67 | "USER2_DOB": "str?", 68 | 69 | "USER3_SEX": "str?", 70 | "USER3_NAME": "str?", 71 | "USER3_HEIGHT": "int?", 72 | "USER3_DOB": "str?" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /mi-scale/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.1.16] - 2020-11-26 2 | ### Changed 3 | - Fixed MQTT Discovery Message 4 | 5 | ## [0.1.15] - 2020-11-26 6 | ### Changed 7 | - Fixed MQTT Discovery Message 8 | 9 | ## [0.1.14] - 2020-11-24 10 | ### Changed 11 | - 2nd attempt to fix executable files (fixes https://github.com/lolouk44/hassio-addons/issues/23) 12 | - Fixed image links in README file 13 | 14 | ## [0.1.13] - 2020-11-24 15 | ### Changed 16 | - Fixed executable files ~~(fixes https://github.com/lolouk44/hassio-addons/issues/23)~~ 17 | 18 | ## [0.1.12] - 2020-11-23 19 | ### Changed 20 | - Fixed workflow for automatic docker images building 21 | 22 | ## [0.1.11] - 2020-11-23 23 | ### Changed 24 | - Remove additional logging for Scale V1 that was used for testing 25 | - Changed timestamp to default python format (fixes https://github.com/lolouk44/xiaomi_mi_scale/issues/29) 26 | - Removed hard-coded 'unit_f_measurement' in the MQTT Discovery (fixes https://github.com/lolouk44/hassio-addons/issues/22) 27 | - Fixed hard coded MQTT Discovery Prefix (fixes https://github.com/lolouk44/xiaomi_mi_scale/issues/35) 28 | - Change measures format to be numbers instead of string where applicable (https://github.com/lolouk44/xiaomi_mi_scale/pull/36) 29 | ### Added 30 | - Created workflow to automatically build docker images on new releases (Thanks @AiiR42 for your help) 31 | 32 | 33 | ## [0.1.10] - 2020-09-09 34 | ### Changed 35 | - Fixed issue with detection of boolean in MQTT_DISCOVERY (https://github.com/lolouk44/hassio-addons/issues/16 and https://github.com/lolouk44/xiaomi_mi_scale/issues/31) 36 | 37 | ## [0.1.9] - 2020-09-08 38 | ### Changed 39 | - Fixed typo in MQTT message following the **breaking change** to snake_case attributes in 0.1.8 40 | 41 | ## [0.1.8] - 2020-09-08 42 | ### Breaking Changes 43 | - Attributes are now snake_case (fixes https://github.com/lolouk44/xiaomi_mi_scale/issues/24) 44 | ### Changed 45 | - Fixed default MQTT Prefix in config.json typo (fixes https://github.com/lolouk44/hassio-addons/issues/6) 46 | - Fixed MQTT Discovery value check to discover 47 | - Changed timestamp to default python format 48 | - Changes the bluetooth reset from reset to down-wait-up (fixes https://github.com/lolouk44/hassio-addons/issues/13) 49 | - Fixed hard coded hci0 to provided hci interface when performing a reset 50 | - Fixed weight in Lbs not detected on Scale V1 (XMTZCO1HM) (fixes https://github.com/lolouk44/xiaomi_mi_scale/issues/28) 51 | - Fixed body calculations for non kg weights 52 | - Updated README 53 | ### Added 54 | - Added unit to attributes 55 | 56 | ## [0.1.7] - 2020-07-06 57 | ### Added 58 | - repository.json to make it a real add-on repo (fixes https://github.com/lolouk44/hassio-addons/issues/4) 59 | ## Changed 60 | - Now truly handles optional config entries(fixes https://github.com/lolouk44/hassio-addons/issues/3) 61 | - MQTT Discovery set wtih retain flag (fixes https://github.com/lolouk44/hassio-addons/issues/2) 62 | - README updated to use Xiaomi Mi Fit App to retrieve the MAC Address (fixes https://github.com/lolouk44/xiaomi_mi_scale/pull/25) 63 | 64 | ## [0.1.6] - 2020-07-01 65 | ### Added 66 | - Docker Image so install is quicker (no local build required). 67 | 68 | ## [0.1.5] - 2020-07-01 69 | ### Added 70 | - MQTT Discovery Support. 71 | 72 | ## [0.1.4] - 2020-06-29 73 | ### Added 74 | - First release (version in line with non Add-On script for ease of maintenance). 75 | -------------------------------------------------------------------------------- /mi-scale/README.md: -------------------------------------------------------------------------------- 1 | # Fork 了大神的小米体脂秤addons,汉化一下。 2 | 作者主页:https://github.com/lolouk44/hassio-addons 3 | 4 | 更多教程 :https://sumju.net 5 | 电报 群 :https://t.me/joinchat/J26zVFGMhWWB1sBTFvcjaA 6 | 电报频道 :https://t.me/itcommander 7 | Twitter : https://twitter.com/itcommander2 8 | Facebook: https://www.facebook.com/itcommander.itcommander.1 9 | 10 | # Xiaomi Mi Scale Add On for Home Assistant 11 | 12 | Add-On for [HomeAssistant](https://www.home-assistant.io/) to read weight measurements from Xiaomi Body Scales. 13 | 14 | ## BREAKING CHANGE: 15 | Please note there was a breaking change in 0.1.8. The MQTT message json attributes are now in lower snake_case to be compliant with Home-Assistant Attributes. 16 | This means Home-Assistant sensor configuration needs to be adjusted. 17 | For example 18 | `value_template: "{{ value_json['Weight'] }}"` 19 | Needs to be replaced with 20 | `value_template: "{{ value_json['weight'] }}"` 21 | (note the lowercase `w` in `weight`) 22 | 23 | ## Supported Scales: 24 | Name | Model | Picture 25 | --- | --- | :---: 26 | [Mi Smart Scale 2](https://www.mi.com/global/scale)                                                                                               | XMTZCO1HM, XMTZC04HM | ![Mi Scale_2](https://raw.githubusercontent.com/lolouk44/xiaomi_mi_scale/master/Screenshots/Mi_Smart_Scale_2_Thumb.png) 27 | [Mi Body Composition Scale](https://www.mi.com/global/mi-body-composition-scale/) | XMTZC02HM | ![Mi Scale](https://raw.githubusercontent.com/lolouk44/xiaomi_mi_scale/master/Screenshots/Mi_Body_Composition_Scale_Thumb.png) 28 | [Mi Body Composition Scale 2](https://c.mi.com/thread-2289389-1-0.html) | XMTZC05HM | ![Mi Body Composition Scale 2](https://raw.githubusercontent.com/lolouk44/xiaomi_mi_scale/master/Screenshots/Mi_Body_Composition_Scale_2_Thumb.png) 29 | 30 | 31 | ## Setup 32 | 33 | 1. Retrieve the scale's MAC Address from the Xiaomi Mi Fit App: 34 | 35 | ![MAC Address](https://raw.githubusercontent.com/lolouk44/xiaomi_mi_scale/master/Screenshots/MAC_Address.png) 36 | 37 | 2. Open Home Assistant and navigate to add-on store. Click on the 3 dots (top right) and select Repositories 38 | 3. Enter `https://github.com/lolouk44/hassio-addons` in the box and click on Add 39 | 4. You should now see Lolouk44 Add-Ons at the bottom list: 40 | 5. Click on Xiaomi Mi Scale then click on Install 41 | 6. Edit the Configuration 42 | 43 | 44 | Option | Type | Required | Description 45 | --- | --- | --- | --- 46 | HCI_DEV | string | No | Bluetooth hci device to use. Defaults to hci0 47 | MISCALE_MAC | string | Yes | Mac address of your scale 48 | MQTT_PREFIX | string | No | MQTT Topic Prefix. Defaults to miscale 49 | MQTT_HOST | string | Yes | MQTT Server (defaults to 127.0.0.1) 50 | MQTT_USERNAME | string | No | Username for MQTT server (comment out if not required) 51 | MQTT_PASSWORD | string | No | Password for MQTT (comment out if not required) 52 | MQTT_PORT | int | No | Defaults to 1883 53 | TIME_INTERVAL | int | No | Time in sec between each query to the scale, to allow other applications to use the Bluetooth module. Defaults to 30 54 | MQTT_DISCOVERY | bool | No | MQTT Discovery for Home Assistant Defaults to true 55 | MQTT_DISCOVERY_PREFIX | string | No | MQTT Discovery Prefix for Home Assistant. Defaults to homeassistant 56 | 57 | 58 | Auto-gender selection/config -- This is used to create the calculations such as BMI, Water/Bone Mass etc... 59 | Up to 3 users possible as long as weights do not overlap! 60 | 61 | Here is the logic used to assign a measured weight to a user: 62 | ``` 63 | if [measured value in kg] is greater than USER1_GT, assign it to USER1 64 | else if [measured value in kg] is less than USER2_LT, assign it to USER2 65 | else assign it to USER3 (e.g. USER2_LT < [measured value in kg] < USER1_GT) 66 | ``` 67 | 68 | Option | Type | Required | Description 69 | --- | --- | --- | --- 70 | USER1_GT | int | Yes | If the weight (in kg) is greater than this number, we'll assume that we're weighing User #1 71 | USER1_SEX | string | Yes | male / female 72 | USER1_NAME | string | Yes | Name of the user 73 | USER1_HEIGHT | int | Yes | Height (in cm) of the user 74 | USER1_DOB | string | Yes | DOB (in yyyy-mm-dd format) 75 | USER2_LT | int | No | If the weight (in kg) is less than this number, we'll assume that we're weighing User #2. Defaults to USER1_GT Value 76 | USER2_SEX | string | No | male / female. Defaults to female 77 | USER2_NAME | string | No | Name of the user. Defaults to Serena 78 | USER2_HEIGHT | int | No |Height (in cm) of the user. Defaults to 95 79 | USER2_DOB | string | No | DOB (in yyyy-mm-dd format). Defaults to 1990-01-01 80 | USER3_SEX | string | No | male / female. Defaults to female 81 | USER3_NAME | string | No | Name of the user. Defaults to Missy 82 | USER3_HEIGHT | int | No |Height (in cm) of the user. Defaults to 150 83 | USER3_DOB | string | No | DOB (in yyyy-mm-dd format). Defaults to 1990-01-01 84 | 85 | 86 | 7. Start the add-on 87 | 88 | 89 | ## Home-Assistant Setup: 90 | Under the `sensor` block, enter as many blocks as users configured in your environment variables: 91 | 92 | ```yaml 93 | - platform: mqtt 94 | name: "Example Name Weight" 95 | state_topic: "miscale/USER_NAME/weight" 96 | value_template: "{{ value_json['weight'] }}" 97 | unit_of_measurement: "kg" 98 | json_attributes_topic: "miscale/USER_NAME/weight" 99 | icon: mdi:scale-bathroom 100 | 101 | - platform: mqtt 102 | name: "Example Name BMI" 103 | state_topic: "miscale/USER_NAME/weight" 104 | value_template: "{{ value_json['bmi'] }}" 105 | icon: mdi:human-pregnant 106 | unit_of_measurement: "kg/m2" 107 | 108 | ``` 109 | 110 | ![Mi Scale](https://raw.githubusercontent.com/lolouk44/xiaomi_mi_scale/master/Screenshots/HA_Lovelace_Card.png) 111 | 112 | ![Mi Scale](https://raw.githubusercontent.com/lolouk44/xiaomi_mi_scale/master/Screenshots/HA_Lovelace_Card_Details.png) 113 | 114 | ## Acknowledgements: 115 | Thanks to @syssi (https://gist.github.com/syssi/4108a54877406dc231d95514e538bde9) and @prototux (https://github.com/wiecosystem/Bluetooth) for their initial code 116 | 117 | Special thanks to [@ned-kelly](https://github.com/ned-kelly) for his help turning a "simple" python script into a fully fledged docker container 118 | 119 | Thanks to [@bpaulin](https://github.com/bpaulin), [@AiiR42](https://github.com/AiiR42) for their PRs and collaboration 120 | -------------------------------------------------------------------------------- /mi-scale/src/body_scales.py: -------------------------------------------------------------------------------- 1 | class bodyScales: 2 | def __init__(self, age, height, sex, weight, scaleType='xiaomi'): 3 | self.age = age 4 | self.height = height 5 | self.sex = sex 6 | self.weight = weight 7 | 8 | if scaleType == 'xiaomi': 9 | self.scaleType = 'xiaomi' 10 | else: 11 | self.scaleType = 'holtek' 12 | 13 | # Get BMI scale 14 | def getBMIScale(self): 15 | if self.scaleType == 'xiaomi': 16 | # Amazfit/new mi fit 17 | #return [18.5, 24, 28] 18 | # Old mi fit // amazfit for body figure 19 | return [18.5, 25.0, 28.0, 32.0] 20 | elif self.scaleType == 'holtek': 21 | return [18.5, 25.0, 30.0] 22 | 23 | # Get fat percentage scale 24 | def getFatPercentageScale(self): 25 | # The included tables where quite strange, maybe bogus, replaced them with better ones... 26 | if self.scaleType == 'xiaomi': 27 | scales = [ 28 | {'min': 0, 'max': 12, 'female': [12.0, 21.0, 30.0, 34.0], 'male': [7.0, 16.0, 25.0, 30.0]}, 29 | {'min': 12, 'max': 14, 'female': [15.0, 24.0, 33.0, 37.0], 'male': [7.0, 16.0, 25.0, 30.0]}, 30 | {'min': 14, 'max': 16, 'female': [18.0, 27.0, 36.0, 40.0], 'male': [7.0, 16.0, 25.0, 30.0]}, 31 | {'min': 16, 'max': 18, 'female': [20.0, 28.0, 37.0, 41.0], 'male': [7.0, 16.0, 25.0, 30.0]}, 32 | {'min': 18, 'max': 40, 'female': [21.0, 28.0, 35.0, 40.0], 'male': [11.0, 17.0, 22.0, 27.0]}, 33 | {'min': 40, 'max': 60, 'female': [22.0, 29.0, 36.0, 41.0], 'male': [12.0, 18.0, 23.0, 28.0]}, 34 | {'min': 60, 'max': 100, 'female': [23.0, 30.0, 37.0, 42.0], 'male': [14.0, 20.0, 25.0, 30.0]}, 35 | ] 36 | 37 | elif self.scaleType == 'holtek': 38 | scales = [ 39 | {'min': 0, 'max': 21, 'female': [18, 23, 30, 35], 'male': [8, 14, 21, 25]}, 40 | {'min': 21, 'max': 26, 'female': [19, 24, 30, 35], 'male': [10, 15, 22, 26]}, 41 | {'min': 26, 'max': 31, 'female': [20, 25, 31, 36], 'male': [11, 16, 21, 27]}, 42 | {'min': 31, 'max': 36, 'female': [21, 26, 33, 36], 'male': [13, 17, 25, 28]}, 43 | {'min': 36, 'max': 41, 'female': [22, 27, 34, 37], 'male': [15, 20, 26, 29]}, 44 | {'min': 41, 'max': 46, 'female': [23, 28, 35, 38], 'male': [16, 22, 27, 30]}, 45 | {'min': 46, 'max': 51, 'female': [24, 30, 36, 38], 'male': [17, 23, 29, 31]}, 46 | {'min': 51, 'max': 56, 'female': [26, 31, 36, 39], 'male': [19, 25, 30, 33]}, 47 | {'min': 56, 'max': 100, 'female': [27, 32, 37, 40], 'male': [21, 26, 31, 34]}, 48 | ] 49 | 50 | for scale in scales: 51 | if self.age >= scale['min'] and self.age < scale['max']: 52 | return scale[self.sex] 53 | 54 | # Get muscle mass scale 55 | def getMuscleMassScale(self): 56 | if self.scaleType == 'xiaomi': 57 | scales = [ 58 | {'min': {'male': 170, 'female': 160}, 'female': [36.5, 42.6], 'male': [49.4, 59.5]}, 59 | {'min': {'male': 160, 'female': 150}, 'female': [32.9, 37.6], 'male': [44.0, 52.5]}, 60 | {'min': {'male': 0, 'female': 0}, 'female': [29.1, 34.8], 'male': [38.5, 46.6]}, 61 | ] 62 | elif self.scaleType == 'holtek': 63 | scales = [ 64 | {'min': {'male': 170, 'female': 170}, 'female': [36.5, 42.5], 'male': [49.5, 59.4]}, 65 | {'min': {'male': 160, 'female': 160}, 'female': [32.9, 37.5], 'male': [44.0, 52.4]}, 66 | {'min': {'male': 0, 'female': 0}, 'female': [29.1, 34.7], 'male': [38.5, 46.5]} 67 | ] 68 | 69 | for scale in scales: 70 | if self.height >= scale['min'][self.sex]: 71 | return scale[self.sex] 72 | 73 | 74 | 75 | # Get water percentage scale 76 | def getWaterPercentageScale(self): 77 | if self.scaleType == 'xiaomi': 78 | if self.sex == 'male': 79 | return [55.0, 65.1] 80 | elif self.sex == 'female': 81 | return [45.0, 60.1] 82 | elif self.scaleType == 'holtek': 83 | return [53, 67] 84 | 85 | 86 | # Get visceral fat scale 87 | def getVisceralFatScale(self): 88 | # Actually the same in mi fit/amazfit and holtek's sdk 89 | return [10.0, 15.0] 90 | 91 | 92 | # Get bone mass scale 93 | def getBoneMassScale(self): 94 | if self.scaleType == 'xiaomi': 95 | scales = [ 96 | {'male': {'min': 75.0, 'scale': [2.0, 4.2]}, 'female': {'min': 60.0, 'scale': [1.8, 3.9]}}, 97 | {'male': {'min': 60.0, 'scale': [1.9, 4.1]}, 'female': {'min': 45.0, 'scale': [1.5, 3.8]}}, 98 | {'male': {'min': 0.0, 'scale': [1.6, 3.9]}, 'female': {'min': 0.0, 'scale': [1.3, 3.6]}}, 99 | ] 100 | 101 | for scale in scales: 102 | if self.weight >= scale[self.sex]['min']: 103 | return scale[self.sex]['scale'] 104 | 105 | elif self.scaleType == 'holtek': 106 | scales = [ 107 | {'female': {'min': 60, 'optimal': 2.5}, 'male': {'min': 75, 'optimal': 3.2}}, 108 | {'female': {'min': 45, 'optimal': 2.2}, 'male': {'min': 69, 'optimal': 2.9}}, 109 | {'female': {'min': 0, 'optimal': 1.8}, 'male': {'min': 0, 'optimal': 2.5}} 110 | ] 111 | 112 | for scale in scales: 113 | if self.weight >= scale[self.sex]['min']: 114 | return [scale[self.sex]['optimal']-1, scale[self.sex]['optimal']+1] 115 | 116 | 117 | # Get BMR scale 118 | def getBMRScale(self): 119 | if self.scaleType == 'xiaomi': 120 | coefficients = { 121 | 'male': {30: 21.6, 50: 20.07, 100: 19.35}, 122 | 'female': {30: 21.24, 50: 19.53, 100: 18.63} 123 | } 124 | elif self.scaleType == 'holtek': 125 | coefficients = { 126 | 'female': {12: 34, 15: 29, 17: 24, 29: 22, 50: 20, 120: 19}, 127 | 'male': {12: 36, 15: 30, 17: 26, 29: 23, 50: 21, 120: 20} 128 | } 129 | 130 | for age, coefficient in coefficients[self.sex].items(): 131 | if self.age < age: 132 | return [self.weight * coefficient] 133 | 134 | 135 | # Get protein scale (hardcoded in mi fit) 136 | def getProteinPercentageScale(self): 137 | # Actually the same in mi fit and holtek's sdk 138 | return [16, 20] 139 | 140 | # Get ideal weight scale (BMI scale converted to weights) 141 | def getIdealWeightScale(self): 142 | scale = [] 143 | for bmiScale in self.getBMIScale(): 144 | scale.append((bmiScale*self.height)*self.height/10000) 145 | return scale 146 | 147 | # Get Body Score scale 148 | def getBodyScoreScale(self): 149 | # very bad, bad, normal, good, better 150 | return [50.0, 60.0, 80.0, 90.0] 151 | 152 | # Return body type scale 153 | def getBodyTypeScale(self): 154 | return ['obese', 'overweight', 'thick-set', 'lack-exerscise', 'balanced', 'balanced-muscular', 'skinny', 'balanced-skinny', 'skinny-muscular'] 155 | 156 | -------------------------------------------------------------------------------- /mi-scale/src/body_score.py: -------------------------------------------------------------------------------- 1 | 2 | # Reverse engineered from amazfit's app (also known as Mi Fit) 3 | from body_scales import bodyScales 4 | class bodyScore: 5 | 6 | def __init__(self, age, sex, height, weight, bmi, bodyfat, muscle, water, visceral_fat, bone, basal_metabolism, protein): 7 | self.age = age 8 | self.sex = sex 9 | self.height = height 10 | self.weight = weight 11 | self.bmi = bmi 12 | self.bodyfat = bodyfat 13 | self.muscle = muscle 14 | self.water = water 15 | self.visceral_fat = visceral_fat 16 | self.bone = bone 17 | self.basal_metabolism = basal_metabolism 18 | self.protein = protein 19 | self.scales = bodyScales(age, height, sex, weight) 20 | 21 | def getBodyScore(self): 22 | score = 100 23 | score -= self.getBmiDeductScore() 24 | score -= self.getBodyFatDeductScore() 25 | score -= self.getMuscleDeductScore() 26 | score -= self.getWaterDeductScore() 27 | score -= self.getVisceralFatDeductScore() 28 | score -= self.getBoneDeductScore() 29 | score -= self.getBasalMetabolismDeductScore() 30 | if self.protein: 31 | score -= self.getProteinDeductScore() 32 | return score 33 | 34 | def getMalus(self, data, min_data, max_data, max_malus, min_malus): 35 | result = ((data - max_data) / (min_data - max_data)) * float(max_malus - min_malus) 36 | if result >= 0.0: 37 | return result 38 | return 0.0 39 | 40 | def getBmiDeductScore(self): 41 | if not self.height >= 90: 42 | # "BMI is not reasonable 43 | return 0.0 44 | 45 | bmi_low = 15.0 46 | bmi_verylow = 14.0 47 | bmi_normal = 18.5 48 | bmi_overweight = 28.0 49 | bmi_obese = 32.0 50 | fat_scale = self.scales.getFatPercentageScale() 51 | 52 | # Perfect range (bmi >= 18.5 and bodyfat not high for adults, bmi >= 15.0 for kids 53 | if self.bmi >= 18.5 and self.age >= 18 and self.bodyfat < fat_scale[2]: 54 | return 0.0 55 | elif self.bmi >= bmi_verylow and self.age < 18 and self.bodyfat < fat_scale[2]: 56 | return 0.0 57 | 58 | # Extremely skinny (bmi < 14) 59 | elif self.bmi <= bmi_verylow: 60 | return 30.0 61 | # Too skinny (bmi between 14 and 15) 62 | elif self.bmi > bmi_verylow and self.bmi < bmi_low: 63 | return self.getMalus(self.bmi, bmi_verylow, bmi_low, 30, 15) + 15.0 64 | # Skinny (for adults, between 15 and 18.5) 65 | elif self.bmi >= bmi_low and self.bmi < bmi_normal and self.age >= 18: 66 | return self.getMalus(self.bmi, 15.0, 18.5, 15, 5) + 5.0 67 | 68 | # Normal or high bmi but too much bodyfat 69 | elif ((self.bmi >= bmi_low and self.age < 18) or (self.bmi >= bmi_normal and self.age >= 18)) and self.bodyfat >= fat_scale[2]: 70 | # Obese 71 | if self.bmi >= bmi_obese: 72 | return 10.0 73 | # Overweight 74 | if self.bmi > bmi_overweight: 75 | return self.getMalus(self.bmi, 28.0, 25.0, 5, 10) + 5.0 76 | else: 77 | return 0.0 78 | 79 | def getBodyFatDeductScore(self): 80 | scale = self.scales.getFatPercentageScale() 81 | 82 | if self.sex == 'male': 83 | best = scale[2] - 3.0 84 | elif self.sex == 'female': 85 | best = scale[2] - 2.0 86 | 87 | # Slighly low in fat or low part or normal fat 88 | if self.bodyfat >= scale[0] and self.bodyfat < best: 89 | return 0.0 90 | elif self.bodyfat >= scale[3]: 91 | return 20.0 92 | else: 93 | # Sightly high body fat 94 | if self.bodyfat < scale[3]: 95 | return self.getMalus(self.bodyfat, scale[3], scale[2], 20, 10) + 10.0 96 | 97 | # High part of normal fat 98 | elif self.bodyfat <= scale[2]: 99 | return self.getMalus(self.bodyfat, scale[2], best, 3, 9) + 3.0 100 | 101 | # Very low in fat 102 | elif self.bodyfat < scale[0]: 103 | return self.getMalus(self.bodyfat, 1.0, scale[0], 3, 10) + 3.0 104 | 105 | 106 | def getMuscleDeductScore(self): 107 | scale = self.scales.getMuscleMassScale() 108 | 109 | # For some reason, there's code to return self.calculate(muscle, normal[0], normal[0]+2.0, 3, 5) + 3.0 110 | # if your muscle is between normal[0] and normal[0] + 2.0, but it's overwritten with 0.0 before return 111 | if self.muscle >= scale[0]: 112 | return 0.0 113 | elif self.muscle < (scale[0] - 5.0): 114 | return 10.0 115 | else: 116 | return self.getMalus(self.muscle, scale[0] - 5.0, scale[0], 10, 5) + 5.0 117 | 118 | # No malus = normal or good; maximum malus (10.0) = less than normal-5.0; 119 | # malus = between 5 and 10, on your water being between normal-5.0 and normal 120 | def getWaterDeductScore(self): 121 | scale = self.scales.getWaterPercentageScale() 122 | 123 | if self.water >= scale[0]: 124 | return 0.0 125 | elif self.water <= (scale[0] - 5.0): 126 | return 10.0 127 | else: 128 | return self.getMalus(self.water, scale[0] - 5.0, scale[0], 10, 5) + 5.0 129 | 130 | # No malus = normal; maximum malus (15.0) = very high; malus = between 10 and 15 131 | # with your visceral fat in your high range 132 | def getVisceralFatDeductScore(self): 133 | scale = self.scales.getVisceralFatScale() 134 | 135 | if self.visceral_fat < scale[0]: 136 | # For some reason, the original app would try to 137 | # return 3.0 if vfat == 8 and 5.0 if vfat == 9 138 | # but i's overwritten with 0.0 anyway before return 139 | return 0.0 140 | elif self.visceral_fat >= scale[1]: 141 | return 15.0 142 | else: 143 | return self.getMalus(self.visceral_fat, scale[1], scale[0], 15, 10) + 10.0 144 | 145 | def getBoneDeductScore(self): 146 | scale = self.scales.getBoneMassScale() 147 | 148 | if self.bone >= scale[0]: 149 | return 0.0 150 | elif self.bone <= (scale[0] - 0.3): 151 | return 10.0 152 | else: 153 | return self.getMalus(self.bone, scale[0] - 0.3, scale[0], 10, 5) + 5.0 154 | 155 | def getBasalMetabolismDeductScore(self): 156 | # Get normal BMR 157 | normal = self.scales.getBMRScale()[0] 158 | 159 | if self.basal_metabolism >= normal: 160 | return 0.0 161 | elif self.basal_metabolism <= (normal - 300): 162 | return 6.0 163 | else: 164 | # It's really + 5.0 in the app, but it's probably a mistake, should be 3.0 165 | return self.getMalus(self.basal_metabolism, normal - 300, normal, 6, 3) + 5.0 166 | 167 | 168 | # Get protein percentage malus 169 | def getProteinDeductScore(self): 170 | # low: 10,16; normal: 16,17 171 | # Check limits 172 | if self.protein > 17.0: 173 | return 0.0 174 | elif self.protein < 10.0: 175 | return 10.0 176 | else: 177 | # Return values for low proteins or normal proteins 178 | if self.protein <= 16.0: 179 | return self.getMalus(self.protein, 10.0, 16.0, 10, 5) + 5.0 180 | elif self.protein <= 17.0: 181 | return self.getMalus(self.protein, 16.0, 17.0, 5, 3) + 3.0 182 | -------------------------------------------------------------------------------- /mi-scale/src/Xiaomi_Scale_Body_Metrics.py: -------------------------------------------------------------------------------- 1 | from math import floor 2 | import sys 3 | from body_scales import bodyScales 4 | 5 | class bodyMetrics: 6 | def __init__(self, weight, height, age, sex, impedance): 7 | self.weight = weight 8 | self.height = height 9 | self.age = age 10 | self.sex = sex 11 | self.impedance = impedance 12 | self.scales = bodyScales(age, height, sex, weight) 13 | 14 | # Check for potential out of boundaries 15 | if self.height > 220: 16 | print("Height is too high (limit: >220cm) or scale is sleeping") 17 | sys.stderr.write('Height is over 220cm\n') 18 | exit() 19 | elif weight < 10 or weight > 200: 20 | print("Weight is either too low or too high (limits: <10kg and >200kg)") 21 | sys.stderr.write('Weight is below 10kg or above 200kg\n') 22 | exit() 23 | elif age > 99: 24 | print("Age is too high (limit >99 years)") 25 | sys.stderr.write('Age is above 99 years\n') 26 | exit() 27 | elif impedance > 3000: 28 | print("Impedance is above 3000 Ohm") 29 | sys.stderr.write('Impedance is above 3000 Ohm\n') 30 | exit() 31 | 32 | # Set the value to a boundary if it overflows 33 | def checkValueOverflow(self, value, minimum, maximum): 34 | if value < minimum: 35 | return minimum 36 | elif value > maximum: 37 | return maximum 38 | else: 39 | return value 40 | 41 | # Get LBM coefficient (with impedance) 42 | def getLBMCoefficient(self): 43 | lbm = (self.height * 9.058 / 100) * (self.height / 100) 44 | lbm += self.weight * 0.32 + 12.226 45 | lbm -= self.impedance * 0.0068 46 | lbm -= self.age * 0.0542 47 | return lbm 48 | 49 | # Get BMR 50 | def getBMR(self): 51 | if self.sex == 'female': 52 | bmr = 864.6 + self.weight * 10.2036 53 | bmr -= self.height * 0.39336 54 | bmr -= self.age * 6.204 55 | else: 56 | bmr = 877.8 + self.weight * 14.916 57 | bmr -= self.height * 0.726 58 | bmr -= self.age * 8.976 59 | 60 | # Capping 61 | if self.sex == 'female' and bmr > 2996: 62 | bmr = 5000 63 | elif self.sex == 'male' and bmr > 2322: 64 | bmr = 5000 65 | return self.checkValueOverflow(bmr, 500, 10000) 66 | 67 | # Get fat percentage 68 | def getFatPercentage(self): 69 | # Set a constant to remove from LBM 70 | if self.sex == 'female' and self.age <= 49: 71 | const = 9.25 72 | elif self.sex == 'female' and self.age > 49: 73 | const = 7.25 74 | else: 75 | const = 0.8 76 | 77 | # Calculate body fat percentage 78 | LBM = self.getLBMCoefficient() 79 | 80 | if self.sex == 'male' and self.weight < 61: 81 | coefficient = 0.98 82 | elif self.sex == 'female' and self.weight > 60: 83 | coefficient = 0.96 84 | if self.height > 160: 85 | coefficient *= 1.03 86 | elif self.sex == 'female' and self.weight < 50: 87 | coefficient = 1.02 88 | if self.height > 160: 89 | coefficient *= 1.03 90 | else: 91 | coefficient = 1.0 92 | fatPercentage = (1.0 - (((LBM - const) * coefficient) / self.weight)) * 100 93 | 94 | # Capping body fat percentage 95 | if fatPercentage > 63: 96 | fatPercentage = 75 97 | return self.checkValueOverflow(fatPercentage, 5, 75) 98 | 99 | # Get water percentage 100 | def getWaterPercentage(self): 101 | waterPercentage = (100 - self.getFatPercentage()) * 0.7 102 | 103 | if (waterPercentage <= 50): 104 | coefficient = 1.02 105 | else: 106 | coefficient = 0.98 107 | 108 | # Capping water percentage 109 | if waterPercentage * coefficient >= 65: 110 | waterPercentage = 75 111 | return self.checkValueOverflow(waterPercentage * coefficient, 35, 75) 112 | 113 | # Get bone mass 114 | def getBoneMass(self): 115 | if self.sex == 'female': 116 | base = 0.245691014 117 | else: 118 | base = 0.18016894 119 | 120 | boneMass = (base - (self.getLBMCoefficient() * 0.05158)) * -1 121 | 122 | if boneMass > 2.2: 123 | boneMass += 0.1 124 | else: 125 | boneMass -= 0.1 126 | 127 | # Capping boneMass 128 | if self.sex == 'female' and boneMass > 5.1: 129 | boneMass = 8 130 | elif self.sex == 'male' and boneMass > 5.2: 131 | boneMass = 8 132 | return self.checkValueOverflow(boneMass, 0.5 , 8) 133 | 134 | # Get muscle mass 135 | def getMuscleMass(self): 136 | muscleMass = self.weight - ((self.getFatPercentage() * 0.01) * self.weight) - self.getBoneMass() 137 | 138 | # Capping muscle mass 139 | if self.sex == 'female' and muscleMass >= 84: 140 | muscleMass = 120 141 | elif self.sex == 'male' and muscleMass >= 93.5: 142 | muscleMass = 120 143 | 144 | return self.checkValueOverflow(muscleMass, 10 ,120) 145 | 146 | # Get Visceral Fat 147 | def getVisceralFat(self): 148 | if self.sex == 'female': 149 | if self.weight > (13 - (self.height * 0.5)) * -1: 150 | subsubcalc = ((self.height * 1.45) + (self.height * 0.1158) * self.height) - 120 151 | subcalc = self.weight * 500 / subsubcalc 152 | vfal = (subcalc - 6) + (self.age * 0.07) 153 | else: 154 | subcalc = 0.691 + (self.height * -0.0024) + (self.height * -0.0024) 155 | vfal = (((self.height * 0.027) - (subcalc * self.weight)) * -1) + (self.age * 0.07) - self.age 156 | else: 157 | if self.height < self.weight * 1.6: 158 | subcalc = ((self.height * 0.4) - (self.height * (self.height * 0.0826))) * -1 159 | vfal = ((self.weight * 305) / (subcalc + 48)) - 2.9 + (self.age * 0.15) 160 | else: 161 | subcalc = 0.765 + self.height * -0.0015 162 | vfal = (((self.height * 0.143) - (self.weight * subcalc)) * -1) + (self.age * 0.15) - 5.0 163 | 164 | return self.checkValueOverflow(vfal, 1 ,50) 165 | 166 | # Get BMI 167 | def getBMI(self): 168 | return self.checkValueOverflow(self.weight/((self.height/100)*(self.height/100)), 10, 90) 169 | 170 | # Get ideal weight (just doing a reverse BMI, should be something better) 171 | def getIdealWeight(self, orig=True): 172 | # Uses mi fit algorithm (or holtek's one) 173 | if orig and self.sex == 'female': 174 | return (self.height - 70) * 0.6 175 | elif orig and self.sex == 'male': 176 | return (self.height - 80) * 0.7 177 | else: 178 | return self.checkValueOverflow((22*self.height)*self.height/10000, 5.5, 198) 179 | 180 | # Get fat mass to ideal (guessing mi fit formula) 181 | def getFatMassToIdeal(self): 182 | mass = (self.weight * (self.getFatPercentage() / 100)) - (self.weight * (self.scales.getFatPercentageScale()[2] / 100)) 183 | if mass < 0: 184 | return {'type': 'to_gain', 'mass': mass*-1} 185 | else: 186 | return {'type': 'to_lose', 'mass': mass} 187 | 188 | # Get protetin percentage (warn: guessed formula) 189 | def getProteinPercentage(self, orig=True): 190 | # Use original algorithm from mi fit (or legacy guess one) 191 | if orig: 192 | proteinPercentage = (self.getMuscleMass() / self.weight) * 100 193 | proteinPercentage -= self.getWaterPercentage() 194 | else: 195 | proteinPercentage = 100 - (floor(self.getFatPercentage() * 100) / 100) 196 | proteinPercentage -= floor(self.getWaterPercentage() * 100) / 100 197 | proteinPercentage -= floor((self.getBoneMass()/self.weight*100) * 100) / 100 198 | 199 | return self.checkValueOverflow(proteinPercentage, 5, 32) 200 | 201 | # Get body type (out of nine possible) 202 | def getBodyType(self): 203 | if self.getFatPercentage() > self.scales.getFatPercentageScale()[2]: 204 | factor = 0 205 | elif self.getFatPercentage() < self.scales.getFatPercentageScale()[1]: 206 | factor = 2 207 | else: 208 | factor = 1 209 | 210 | if self.getMuscleMass() > self.scales.getMuscleMassScale()[1]: 211 | return 2 + (factor * 3) 212 | elif self.getMuscleMass() < self.scales.getMuscleMassScale()[0]: 213 | return (factor * 3) 214 | else: 215 | return 1 + (factor * 3) 216 | 217 | # Get Metabolic Age 218 | def getMetabolicAge(self): 219 | if self.sex == 'female': 220 | metabolicAge = (self.height * -1.1165) + (self.weight * 1.5784) + (self.age * 0.4615) + (self.impedance * 0.0415) + 83.2548 221 | else: 222 | metabolicAge = (self.height * -0.7471) + (self.weight * 0.9161) + (self.age * 0.4184) + (self.impedance * 0.0517) + 54.2267 223 | return self.checkValueOverflow(metabolicAge, 15, 80) 224 | -------------------------------------------------------------------------------- /mi-scale/src/Xiaomi_Scale.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import print_function 4 | import argparse 5 | import binascii 6 | import time 7 | import os 8 | import sys 9 | import subprocess 10 | from bluepy import btle 11 | from bluepy.btle import Scanner, BTLEDisconnectError, BTLEManagementError, DefaultDelegate 12 | import paho.mqtt.publish as publish 13 | from datetime import datetime 14 | import json 15 | 16 | import Xiaomi_Scale_Body_Metrics 17 | 18 | 19 | 20 | # First Log msg 21 | sys.stdout.write(' \n') 22 | sys.stdout.write('-------------------------------------\n') 23 | sys.stdout.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Starting Xiaomi mi Scale...\n") 24 | 25 | # Configuraiton... 26 | # Trying To Load Config From options.json (HA Add-On) 27 | try: 28 | with open('/data/options.json') as json_file: 29 | sys.stdout.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Loading Config From Add-On Options...\n") 30 | data = json.load(json_file) 31 | try: 32 | MISCALE_MAC = data["MISCALE_MAC"] 33 | except: 34 | sys.stderr.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - MAC Address not provided...\n") 35 | raise 36 | try: 37 | MQTT_USERNAME = data["MQTT_USERNAME"] 38 | except: 39 | MQTT_USERNAME = "username" 40 | pass 41 | try: 42 | MQTT_PASSWORD = data["MQTT_PASSWORD"] 43 | except: 44 | MQTT_PASSWORD = None 45 | pass 46 | try: 47 | MQTT_HOST = data["MQTT_HOST"] 48 | except: 49 | sys.stderr.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - MQTT Host not provided...\n") 50 | raise 51 | try: 52 | MQTT_PORT = int(data["MQTT_PORT"]) 53 | except: 54 | MQTT_PORT = 1883 55 | pass 56 | try: 57 | MQTT_PREFIX = data["MQTT_PREFIX"] 58 | except: 59 | MQTT_PREFIX = "miscale" 60 | pass 61 | try: 62 | TIME_INTERVAL = int(data["TIME_INTERVAL"]) 63 | except: 64 | TIME_INTERVAL = 30 65 | pass 66 | try: 67 | MQTT_DISCOVERY = data["MQTT_DISCOVERY"] 68 | except: 69 | MQTT_DISCOVERY = True 70 | pass 71 | try: 72 | MQTT_DISCOVERY_PREFIX = data["MQTT_DISCOVERY_PREFIX"] 73 | except: 74 | MQTT_DISCOVERY_PREFIX = "homeassistant" 75 | pass 76 | try: 77 | HCI_DEV = data["HCI_DEV"][-1] 78 | except: 79 | HCI_DEV = "hci0"[-1] 80 | pass 81 | try: 82 | USER1_GT = int(data["USER1_GT"]) 83 | except: 84 | sys.stderr.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - USER1_GT not provided...\n") 85 | raise 86 | try: 87 | USER1_SEX = data["USER1_SEX"] 88 | except: 89 | sys.stderr.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - USER1_SEX not provided...\n") 90 | raise 91 | try: 92 | USER1_NAME = data["USER1_NAME"] 93 | except: 94 | sys.stderr.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - USER1_NAME not provided...\n") 95 | raise 96 | try: 97 | USER1_HEIGHT = int(data["USER1_HEIGHT"]) 98 | except: 99 | sys.stderr.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - USER1_HEIGHT not provided...\n") 100 | raise 101 | try: 102 | USER1_DOB = data["USER1_DOB"] 103 | except: 104 | sys.stderr.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - USER1_DOB not provided...\n") 105 | raise 106 | try: 107 | USER2_LT = int(data["USER2_LT"]) 108 | except: 109 | USER2_LT = USER1_GT 110 | pass 111 | try: 112 | USER2_SEX = data["USER2_SEX"] 113 | except: 114 | USER2_SEX = "female" 115 | pass 116 | try: 117 | USER2_NAME = data["USER2_NAME"] 118 | except: 119 | USER2_NAME = "Serena" 120 | pass 121 | try: 122 | USER2_HEIGHT = int(data["USER2_HEIGHT"]) 123 | except: 124 | USER2_HEIGHT = 95 125 | pass 126 | try: 127 | USER2_DOB = data["USER2_DOB"] 128 | except: 129 | USER2_DOB = "1990-01-01" 130 | pass 131 | try: 132 | USER3_SEX = data["USER3_SEX"] 133 | except: 134 | USER3_SEX = "female" 135 | pass 136 | try: 137 | USER3_NAME = data["USER3_NAME"] 138 | except: 139 | USER3_NAME = "Missy" 140 | pass 141 | try: 142 | USER3_HEIGHT = int(data["USER3_HEIGHT"]) 143 | except: 144 | USER3_HEIGHT = 150 145 | pass 146 | try: 147 | USER3_DOB = data["USER3_DOB"] 148 | except: 149 | USER3_DOB = "1990-01-01" 150 | pass 151 | sys.stdout.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Config Loaded...\n") 152 | 153 | # Failed to open options.json, Loading Config From Environment (Not HA Add-On) 154 | except FileNotFoundError: 155 | pass 156 | sys.stdout.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Loading Config From OS Environment...\n") 157 | MISCALE_MAC = os.getenv('MISCALE_MAC', '') 158 | MQTT_USERNAME = os.getenv('MQTT_USERNAME', 'username') 159 | MQTT_PASSWORD = os.getenv('MQTT_PASSWORD', None) 160 | MQTT_HOST = os.getenv('MQTT_HOST', '127.0.0.1') 161 | MQTT_PORT = int(os.getenv('MQTT_PORT', 1883)) 162 | MQTT_PREFIX = os.getenv('MQTT_PREFIX', 'miscale') 163 | TIME_INTERVAL = int(os.getenv('TIME_INTERVAL', 30)) 164 | MQTT_DISCOVERY = os.getenv('MQTT_DISCOVERY',True) 165 | if MQTT_DISCOVERY.lower() in ['true', '1', 'y', 'yes']: 166 | MQTT_DISCOVERY = True 167 | else: 168 | MQTT_DISCOVERY = False 169 | MQTT_DISCOVERY_PREFIX = os.getenv('MQTT_DISCOVERY_PREFIX','homeassistant') 170 | HCI_DEV = os.getenv('HCI_DEV', 'hci0')[-1] 171 | 172 | # User Variables... 173 | USER1_GT = int(os.getenv('USER1_GT', '70')) # If the weight is greater than this number, we'll assume that we're weighing User #1 174 | USER1_SEX = os.getenv('USER1_SEX', 'male') 175 | USER1_NAME = os.getenv('USER1_NAME', 'David') # Name of the user 176 | USER1_HEIGHT = int(os.getenv('USER1_HEIGHT', '175')) # Height (in cm) of the user 177 | USER1_DOB = os.getenv('USER1_DOB', '1988-09-30') # DOB (in yyyy-mm-dd format) 178 | 179 | USER2_LT = int(os.getenv('USER2_LT', '55')) # If the weight is less than this number, we'll assume that we're weighing User #2 180 | USER2_SEX = os.getenv('USER2_SEX', 'female') 181 | USER2_NAME = os.getenv('USER2_NAME', 'Joanne') # Name of the user 182 | USER2_HEIGHT = int(os.getenv('USER2_HEIGHT', '155')) # Height (in cm) of the user 183 | USER2_DOB = os.getenv('USER2_DOB', '1988-10-20') # DOB (in yyyy-mm-dd format) 184 | 185 | USER3_SEX = os.getenv('USER3_SEX', 'male') 186 | USER3_NAME = os.getenv('USER3_NAME', 'Unknown User') # Name of the user 187 | USER3_HEIGHT = int(os.getenv('USER3_HEIGHT', '175')) # Height (in cm) of the user 188 | USER3_DOB = os.getenv('USER3_DOB', '1988-01-01') # DOB (in yyyy-mm-dd format) 189 | sys.stdout.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Config Loaded...\n") 190 | 191 | OLD_MEASURE = '' 192 | 193 | def discovery(): 194 | for MQTTUser in (USER1_NAME,USER2_NAME,USER3_NAME): 195 | message = '{"name": "' + MQTTUser + ' 体重",' 196 | message+= '"state_topic": "' + MQTT_PREFIX + '/' + MQTTUser + '/weight","value_template": "{{ value_json.重量 }}",' 197 | message+= '"json_attributes_topic": "' + MQTT_PREFIX + '/' + MQTTUser + '/weight","icon": "mdi:scale-bathroom"}' 198 | publish.single( 199 | MQTT_DISCOVERY_PREFIX + '/sensor/' + MQTT_PREFIX + '/' + MQTTUser + '/config', 200 | message, 201 | retain=True, 202 | hostname=MQTT_HOST, 203 | port=MQTT_PORT, 204 | auth={'username':MQTT_USERNAME, 'password':MQTT_PASSWORD} 205 | ) 206 | sys.stdout.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Discovery Completed...\n") 207 | 208 | 209 | class ScanProcessor(): 210 | def GetAge(self, d1): 211 | d1 = datetime.strptime(d1, "%Y-%m-%d") 212 | d2 = datetime.strptime(datetime.today().strftime('%Y-%m-%d'),'%Y-%m-%d') 213 | return abs((d2 - d1).days)/365 214 | 215 | def __init__(self): 216 | DefaultDelegate.__init__(self) 217 | 218 | def handleDiscovery(self, dev, isNewDev, isNewData): 219 | global OLD_MEASURE 220 | if dev.addr == MISCALE_MAC.lower() and isNewDev: 221 | for (sdid, desc, data) in dev.getScanData(): 222 | ### Xiaomi V1 Scale ### 223 | if data.startswith('1d18') and sdid == 22: 224 | measunit = data[4:6] 225 | measured = int((data[8:10] + data[6:8]), 16) * 0.01 226 | unit = '' 227 | if measunit.startswith(('03', 'a3')): unit = 'lbs' 228 | if measunit.startswith(('12', 'b2')): unit = 'jin' 229 | if measunit.startswith(('22', 'a2')): unit = 'kg' ; measured = measured / 2 230 | if unit: 231 | if OLD_MEASURE != round(measured, 2): 232 | self._publish(round(measured, 2), unit, str(datetime.now()), "", "") 233 | OLD_MEASURE = round(measured, 2) 234 | 235 | ### Xiaomi V2 Scale ### 236 | if data.startswith('1b18') and sdid == 22: 237 | data2 = bytes.fromhex(data[4:]) 238 | ctrlByte1 = data2[1] 239 | isStabilized = ctrlByte1 & (1<<5) 240 | hasImpedance = ctrlByte1 & (1<<1) 241 | 242 | measunit = data[4:6] 243 | measured = int((data[28:30] + data[26:28]), 16) * 0.01 244 | unit = '' 245 | if measunit == "03": unit = 'lbs' 246 | if measunit == "02": unit = 'kg' ; measured = measured / 2 247 | #mitdatetime = datetime.strptime(str(int((data[10:12] + data[8:10]), 16)) + " " + str(int((data[12:14]), 16)) +" "+ str(int((data[14:16]), 16)) +" "+ str(int((data[16:18]), 16)) +" "+ str(int((data[18:20]), 16)) +" "+ str(int((data[20:22]), 16)), "%Y %m %d %H %M %S") 248 | miimpedance = str(int((data[24:26] + data[22:24]), 16)) 249 | if unit and isStabilized: 250 | if OLD_MEASURE != round(measured, 2) + int(miimpedance): 251 | self._publish(round(measured, 2), unit, str(datetime.now()), hasImpedance, miimpedance) 252 | OLD_MEASURE = round(measured, 2) + int(miimpedance) 253 | 254 | 255 | def _publish(self, weight, unit, mitdatetime, hasImpedance, miimpedance): 256 | if unit == "lbs": calcweight = round(weight * 0.4536, 2) 257 | if unit == "jin": calcweight = round(weight * 0.5, 2) 258 | if unit == "kg": calcweight = weight 259 | if int(calcweight) > USER1_GT: 260 | user = USER1_NAME 261 | height = USER1_HEIGHT 262 | age = self.GetAge(USER1_DOB) 263 | sex = USER1_SEX 264 | elif int(calcweight) < USER2_LT: 265 | user = USER2_NAME 266 | height = USER2_HEIGHT 267 | age = self.GetAge(USER2_DOB) 268 | sex = USER2_SEX 269 | else: 270 | user = USER3_NAME 271 | height = USER3_HEIGHT 272 | age = self.GetAge(USER3_DOB) 273 | sex = USER3_SEX 274 | 275 | lib = Xiaomi_Scale_Body_Metrics.bodyMetrics(calcweight, height, age, sex, 0) 276 | message = '{' 277 | message += '"重量":' + "{:.2f}".format(weight) 278 | message += ',"重量单位":"' + str(unit) + '"' 279 | message += ',"BMI身体质量指数":' + "{:.2f}".format(lib.getBMI()) 280 | message += ',"基本代谢":' + "{:.2f}".format(lib.getBMR()) 281 | message += ',"内脏脂肪":' + "{:.2f}".format(lib.getVisceralFat()) 282 | 283 | if hasImpedance: 284 | lib = Xiaomi_Scale_Body_Metrics.bodyMetrics(calcweight, height, age, sex, int(miimpedance)) 285 | bodyscale = ['Obese', 'Overweight', 'Thick-set', 'Lack-exerscise', 'Balanced', 'Balanced-muscular', 'Skinny', 'Balanced-skinny', 'Skinny-muscular'] 286 | message += ',"去脂体重":' + "{:.2f}".format(lib.getLBMCoefficient()) 287 | message += ',"体脂":' + "{:.2f}".format(lib.getFatPercentage()) 288 | message += ',"水分":' + "{:.2f}".format(lib.getWaterPercentage()) 289 | message += ',"骨量":' + "{:.2f}".format(lib.getBoneMass()) 290 | message += ',"肌肉量":' + "{:.2f}".format(lib.getMuscleMass()) 291 | message += ',"蛋白质":' + "{:.2f}".format(lib.getProteinPercentage()) 292 | if str(bodyscale[lib.getBodyType()]) == "Overweight": 293 | bodytype = "超重型" 294 | elif str(bodyscale[lib.getBodyType()]) == "Obese": 295 | bodytype = "肥胖型" 296 | elif str(bodyscale[lib.getBodyType()]) == "Thick-set": 297 | bodytype = "壮实型" 298 | elif str(bodyscale[lib.getBodyType()]) == "Lack-exerscise": 299 | bodytype = "缺乏运动型" 300 | elif str(bodyscale[lib.getBodyType()]) == "Balanced": 301 | bodytype = "平衡型" 302 | elif str(bodyscale[lib.getBodyType()]) == "Balanced-muscular": 303 | bodytype = "平衡肌肉型" 304 | elif str(bodyscale[lib.getBodyType()]) == "Skinny": 305 | bodytype = "偏瘦型" 306 | elif str(bodyscale[lib.getBodyType()]) == "Balanced-skinny": 307 | bodytype = "平衡瘦型" 308 | elif str(bodyscale[lib.getBodyType()]) == "Skinny-muscular": 309 | bodytype = "瘦肌肉型" 310 | message += ',"身体类型":"' + bodytype + '"' 311 | message += ',"代谢年龄":' + "{:.0f}".format(lib.getMetabolicAge()) 312 | 313 | message += ',"测量时间":"' + mitdatetime + '"' 314 | message += '}' 315 | try: 316 | sys.stdout.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Publishing data to topic {MQTT_PREFIX + '/' + user + '/weight'}: {message}\n") 317 | publish.single( 318 | MQTT_PREFIX + '/' + user + '/weight', 319 | message, 320 | # qos=1, #Removed qos=1 as incorrect connection details will result in the client waiting for ack from broker 321 | retain=True, 322 | hostname=MQTT_HOST, 323 | port=MQTT_PORT, 324 | auth={'username':MQTT_USERNAME, 'password':MQTT_PASSWORD} 325 | ) 326 | sys.stdout.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Data Published ...\n") 327 | except Exception as error: 328 | sys.stderr.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Could not publish to MQTT: {error}\n") 329 | raise 330 | 331 | def main(): 332 | if MQTT_DISCOVERY: 333 | discovery() 334 | BluetoothFailCounter = 0 335 | while True: 336 | try: 337 | scanner = btle.Scanner(HCI_DEV).withDelegate(ScanProcessor()) 338 | scanner.scan(5) # Adding passive=True to try and fix issues on RPi devices 339 | except BTLEDisconnectError as error: 340 | sys.stderr.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - btle disconnected: {error}\n") 341 | pass 342 | except BTLEManagementError as error: 343 | sys.stderr.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Bluetooth connection error: {error}\n") 344 | if BluetoothFailCounter >= 4: 345 | sys.stderr.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - 5+ Bluetooth connection errors. Resetting Bluetooth...\n") 346 | cmd = 'hciconfig hci' + HCI_DEV + ' down' 347 | ps = subprocess.Popen(cmd, shell=True) 348 | time.sleep(1) 349 | cmd = 'hciconfig hci' + HCI_DEV + ' up' 350 | ps = subprocess.Popen(cmd, shell=True) 351 | time.sleep(30) 352 | BluetoothFailCounter = 0 353 | else: 354 | BluetoothFailCounter+=1 355 | pass 356 | except Exception as error: 357 | sys.stderr.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Error while running the script: {error}\n") 358 | pass 359 | else: 360 | BluetoothFailCounter = 0 361 | time.sleep(TIME_INTERVAL) 362 | 363 | if __name__ == "__main__": 364 | main() 365 | --------------------------------------------------------------------------------