initial commit

This commit is contained in:
lolorpi 2020-06-30 19:18:55 +01:00
commit b700f506cf
19 changed files with 1076 additions and 0 deletions

31
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,31 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behaviour, including error message if any.
**Expected behaviour**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Scale (please complete the following information):**
- Name
- Model #
**Device running Home Assistant (please complete the following information):**
- Device used to run the Script/Container [e.g. Raspberry Pi, NUC]
- Bluetooth device used [e.g. Built-in, USB Dongle]
**Additional context**
Add any other context about the problem here.

View File

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

3
CHANGELOG.md Normal file
View File

@ -0,0 +1,3 @@
## [0.1.4] - 2020-06-29
### Added
- First release (version in line with non Add-On script for ease of maintenance).

18
Dockerfile Normal file
View File

@ -0,0 +1,18 @@
FROM python:3.8-slim
WORKDIR /opt/miscale
COPY src /opt/miscale
RUN apt-get update && apt-get install -y \
bluez \
python-pip \
libglib2.0-dev && \
rm -rf /var/lib/apt/lists/*
RUN pip install -r requirements.txt
# Copy in docker scripts to root of container...
COPY dockerscripts/ /
ENTRYPOINT ["/entrypoint.sh"]
CMD ["/cmd.sh"]

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 lolouk44
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

97
README.md Normal file
View File

@ -0,0 +1,97 @@
# Xiaomi Mi Scale Add On for Home Assistant
Add-On for [HomeAssistant](https://www.home-assistant.io/) to read weight measurements from Xiaomi Body Scales.
## Supported Scales:
Name | Model | Picture
--- | --- | :---:
[Mi Smart Scale 2](https://www.mi.com/global/scale)                                                                                               | XMTZC04HM | ![Mi Scale_2](https://github.com/lolouk44/xiaomi_mi_scale/blob/master/Screenshots/Mi_Smart_Scale_2_Thumb.png)
[Mi Body Composition Scale](https://www.mi.com/global/mi-body-composition-scale/) | XMTZC02HM | ![Mi Scale](https://github.com/lolouk44/xiaomi_mi_scale/blob/master/Screenshots/Mi_Body_Composition_Scale_Thumb.png)
[Mi Body Composition Scale 2](https://c.mi.com/thread-2289389-1-0.html) | XMTZC05HM | ![Mi Body Composition Scale 2](https://github.com/lolouk44/xiaomi_mi_scale/blob/master/Screenshots/Mi_Body_Composition_Scale_2_Thumb.png)
## Setup
1. Retrieve the scale's MAC Address based on the Xiaomi Mi Fit app.
2. Clone this repository
`git clone https://github.com/lolouk44/xiaomi_mi_scale_ha_add_on`
3. Create a new directory xiaomi_mi_scale in the folder addons in your Home Assistant installation and place all files in it via SSH / Samba
![Add-On](Screenshots/addon.png)
4. Open Home Assistant and navigate to add-on store and clock the reload button on the top right corner. Now you should see the Xiaomi Mi Scale as a local add-on
![Add-On Store](Screenshots/addon_store.png)
5. Install the add-on (takes a while as the container is built locally)
6. Edit the Configuration
Option | Type | Required | Description
--- | --- | --- | ---
HCI_DEV | string | No | Bluetooth hci device to use. Defaults to hci0
MISCALE_MAC | string | Yes | Mac address of your scale
MQTT_PREFIX | string | No | MQTT Topic Prefix. Defaults to miscale
MQTT_HOST | string | Yes | MQTT Server (defaults to 127.0.0.1)
MQTT_USERNAME | string | No | Username for MQTT server (comment out if not required)
MQTT_PASSWORD | string | No | Password for MQTT (comment out if not required)
MQTT_PORT | int | No | Defaults to 1883
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
Auto-gender selection/config -- This is used to create the calculations such as BMI, Water/Bone Mass etc...
Up to 3 users possible as long as weights do not overlap!
Option | Type | Required | Description
--- | --- | --- | ---
USER1_GT | int | Yes | If the weight is greater than this number, we'll assume that we're weighing User #1
USER1_SEX | string | Yes | male / female
USER1_NAME | string | Yes | Name of the user
USER1_HEIGHT | int | Yes | Height (in cm) of the user
USER1_DOB | string | Yes | DOB (in yyyy-mm-dd format)
USER2_LT | int | No | If the weight is less than this number, we'll assume that we're weighing User #2
USER2_SEX | string | No | male / female
USER2_NAME | string | No | Name of the user
USER2_HEIGHT | int | No |Height (in cm) of the user
USER2_DOB | string | No | DOB (in yyyy-mm-dd format)
USER3_SEX | string | No | male / female
USER3_NAME | string | No | Name of the user
USER3_HEIGHT | int | No |Height (in cm) of the user
USER3_DOB | string | No | DOB (in yyyy-mm-dd format)
7. Start the add-on
## Home-Assistant Setup:
Under the `sensor` block, enter as many blocks as users configured in your environment variables:
```yaml
- platform: mqtt
name: "Example Name Weight"
state_topic: "miScale/USER_NAME/weight"
value_template: "{{ value_json['Weight'] }}"
unit_of_measurement: "kg"
json_attributes_topic: "miScale/USER_NAME/weight"
icon: mdi:scale-bathroom
- platform: mqtt
name: "Example Name BMI"
state_topic: "miScale/USER_NAME/weight"
value_template: "{{ value_json['BMI'] }}"
icon: mdi:human-pregnant
```
![Mi Scale](https://github.com/lolouk44/xiaomi_mi_scale/blob/master/Screenshots/HA_Lovelace_Card.png)
![Mi Scale](https://github.com/lolouk44/xiaomi_mi_scale/blob/master/Screenshots/HA_Lovelace_Card_Details.png)
## Acknowledgements:
Thanks to @syssi (https://gist.github.com/syssi/4108a54877406dc231d95514e538bde9) and @prototux (https://github.com/wiecosystem/Bluetooth) for their initial code
Special thanks to [@ned-kelly](https://github.com/ned-kelly) for his help turning a "simple" python script into a fully fledged docker container
Thanks to [@bpaulin](https://github.com/bpaulin) for his PRs and collaboration

BIN
Screenshots/addon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
Screenshots/addon_store.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

69
config.json Normal file
View File

@ -0,0 +1,69 @@
{
"name": "Xiaomi Mi Scale",
"version": "0.1.3",
"slug": "xiaomi_mi_scale",
"description": "Read weight measurements from Xiamomi scale via BLE",
"url": "https://github.com/lolouk44/xiaomi_mi_scale_ha_add_on",
"arch": ["armhf", "armv7", "aarch64", "amd64", "i386"],
"startup": "before",
"boot": "auto",
"panel_admin": false,
"host_network": true,
"privileged": ["NET_ADMIN", "SYS_ADMIN"],
"options": {
"HCI_DEV": "hci0",
"MISCALE_MAC": "00:00:00:00:00:00",
"MQTT_PREFIX": "miScale",
"MQTT_HOST": "192.168.0.1",
"MQTT_USERNAME": "",
"MQTT_PASSWORD": "",
"MQTT_PORT": 1883,
"TIME_INTERVAL": 30,
"USER1_GT": 70,
"USER1_SEX": "male",
"USER1_NAME": "Jo",
"USER1_HEIGHT": 175,
"USER1_DOB": "1990-01-01",
"USER2_LT": 35,
"USER2_SEX": "female",
"USER2_NAME": "Serena",
"USER2_HEIGHT": 95,
"USER2_DOB": "1990-01-01",
"USER3_SEX": "female",
"USER3_NAME": "Missy",
"USER3_HEIGHT": 150,
"USER3_DOB": "1990-01-01"
},
"schema": {
"HCI_DEV": "str",
"MISCALE_MAC": "str",
"MQTT_PREFIX": "str",
"MQTT_HOST": "str",
"MQTT_USERNAME": "str",
"MQTT_PASSWORD": "str",
"MQTT_PORT": "int",
"TIME_INTERVAL": "int",
"USER1_GT": "int",
"USER1_SEX": "str",
"USER1_NAME": "str",
"USER1_HEIGHT": "int",
"USER1_DOB": "str",
"USER2_LT": "int",
"USER2_SEX": "str",
"USER2_NAME": "str",
"USER2_HEIGHT": "int",
"USER2_DOB": "str",
"USER3_SEX": "str",
"USER3_NAME": "str",
"USER3_HEIGHT": "int",
"USER3_DOB": "str"
}
}

2
dockerscripts/cmd.sh Normal file
View File

@ -0,0 +1,2 @@
set -e
python3 -u /opt/miscale/Xiaomi_Scale.py

View File

@ -0,0 +1,3 @@
#!/bin/sh
set -e
exec "$@"

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

227
src/Xiaomi_Scale.py Normal file
View File

@ -0,0 +1,227 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
from __future__ import print_function
import argparse
import binascii
import time
import os
import sys
import subprocess
from bluepy import btle
from bluepy.btle import Scanner, BTLEDisconnectError, BTLEManagementError, DefaultDelegate
import paho.mqtt.publish as publish
from datetime import datetime
import json
import Xiaomi_Scale_Body_Metrics
# First Log msg
sys.stdout.write(' \n')
sys.stdout.write('-------------------------------------\n')
sys.stdout.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Starting Xiaomi mi Scale...\n")
# Configuraiton...
# Trying To Load Config From options.json (HA Add-On)
try:
with open('/data/options.json') as json_file:
sys.stdout.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Loading Config From Add-On Options...\n")
data = json.load(json_file)
MISCALE_MAC = data["MISCALE_MAC"]
if(data["MQTT_USERNAME"] == ""):
MQTT_USERNAME = None
else:
MQTT_USERNAME = data["MQTT_USERNAME"]
if(data["MQTT_PASSWORD"] == ""):
MQTT_PASSWORD = None
else:
MQTT_PASSWORD = data["MQTT_PASSWORD"]
MQTT_HOST = data["MQTT_HOST"]
MQTT_PORT = int(data["MQTT_PORT"])
MQTT_PREFIX = data["MQTT_PREFIX"]
TIME_INTERVAL = int(data["TIME_INTERVAL"])
HCI_DEV = data["HCI_DEV"][-1]
OLD_MEASURE = ''
# User Variables...
USER1_GT = int(data["USER1_GT"])
USER1_SEX = data["USER1_SEX"]
USER1_NAME = data["USER1_NAME"]
USER1_HEIGHT = int(data["USER1_HEIGHT"])
USER1_DOB = data["USER1_DOB"]
USER2_LT = int(data["USER2_LT"])
USER2_SEX = data["USER2_SEX"]
USER2_NAME = data["USER2_NAME"]
USER2_HEIGHT = int(data["USER2_HEIGHT"])
USER2_DOB = data["USER2_DOB"]
USER3_SEX = data["USER3_SEX"]
USER3_NAME = data["USER3_NAME"]
USER3_HEIGHT = int(data["USER3_HEIGHT"])
USER3_DOB = data["USER3_DOB"]
sys.stdout.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Config Loaded...\n")
# Failed to open options.json, Loading Config From Environment (Not HA Add-On)
except FileNotFoundError:
pass
sys.stdout.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Loading Config From OS Environment...\n")
MISCALE_MAC = os.getenv('MISCALE_MAC', '')
MQTT_USERNAME = os.getenv('MQTT_USERNAME', 'username')
MQTT_PASSWORD = os.getenv('MQTT_PASSWORD', None)
MQTT_HOST = os.getenv('MQTT_HOST', '127.0.0.1')
MQTT_PORT = int(os.getenv('MQTT_PORT', 1883))
MQTT_PREFIX = os.getenv('MQTT_PREFIX', 'miscale')
TIME_INTERVAL = int(os.getenv('TIME_INTERVAL', 30))
HCI_DEV = os.getenv('HCI_DEV', 'hci0')[-1]
OLD_MEASURE = ''
# User Variables...
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
USER1_SEX = os.getenv('USER1_SEX', 'male')
USER1_NAME = os.getenv('USER1_NAME', 'David') # Name of the user
USER1_HEIGHT = int(os.getenv('USER1_HEIGHT', '175')) # Height (in cm) of the user
USER1_DOB = os.getenv('USER1_DOB', '1988-09-30') # DOB (in yyyy-mm-dd format)
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
USER2_SEX = os.getenv('USER2_SEX', 'female')
USER2_NAME = os.getenv('USER2_NAME', 'Joanne') # Name of the user
USER2_HEIGHT = int(os.getenv('USER2_HEIGHT', '155')) # Height (in cm) of the user
USER2_DOB = os.getenv('USER2_DOB', '1988-10-20') # DOB (in yyyy-mm-dd format)
USER3_SEX = os.getenv('USER3_SEX', 'male')
USER3_NAME = os.getenv('USER3_NAME', 'Unknown User') # Name of the user
USER3_HEIGHT = int(os.getenv('USER3_HEIGHT', '175')) # Height (in cm) of the user
USER3_DOB = os.getenv('USER3_DOB', '1988-01-01') # DOB (in yyyy-mm-dd format)
sys.stdout.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Config Loaded...\n")
class ScanProcessor():
def GetAge(self, d1):
d1 = datetime.strptime(d1, "%Y-%m-%d")
d2 = datetime.strptime(datetime.today().strftime('%Y-%m-%d'),'%Y-%m-%d')
return abs((d2 - d1).days)/365
def __init__(self):
DefaultDelegate.__init__(self)
def handleDiscovery(self, dev, isNewDev, isNewData):
global OLD_MEASURE
if dev.addr == MISCALE_MAC.lower() and isNewDev:
for (sdid, desc, data) in dev.getScanData():
### Xiaomi V1 Scale ###
if data.startswith('1d18') and sdid == 22:
measunit = data[4:6]
measured = int((data[8:10] + data[6:8]), 16) * 0.01
unit = ''
if measunit.startswith(('03', 'b3')): unit = 'lbs'
if measunit.startswith(('12', 'b2')): unit = 'jin'
if measunit.startswith(('22', 'a2')): unit = 'kg' ; measured = measured / 2
if unit:
if OLD_MEASURE != round(measured, 2):
self._publish(round(measured, 2), unit, str(datetime.today().strftime('%Y-%m-%d-%H:%M:%S')), "", "")
OLD_MEASURE = round(measured, 2)
### Xiaomi V2 Scale ###
if data.startswith('1b18') and sdid == 22:
data2 = bytes.fromhex(data[4:])
ctrlByte1 = data2[1]
isStabilized = ctrlByte1 & (1<<5)
hasImpedance = ctrlByte1 & (1<<1)
measunit = data[4:6]
measured = int((data[28:30] + data[26:28]), 16) * 0.01
unit = ''
if measunit == "03": unit = 'lbs'
if measunit == "02": unit = 'kg' ; measured = measured / 2
#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")
miimpedance = str(int((data[24:26] + data[22:24]), 16))
if unit and isStabilized:
if OLD_MEASURE != round(measured, 2) + int(miimpedance):
self._publish(round(measured, 2), unit, str(datetime.today().strftime('%Y-%m-%d-%H:%M:%S')), hasImpedance, miimpedance)
OLD_MEASURE = round(measured, 2) + int(miimpedance)
def _publish(self, weight, unit, mitdatetime, hasImpedance, miimpedance):
if int(weight) > USER1_GT:
user = USER1_NAME
height = USER1_HEIGHT
age = self.GetAge(USER1_DOB)
sex = USER1_SEX
elif int(weight) < USER2_LT:
user = USER2_NAME
height = USER2_HEIGHT
age = self.GetAge(USER2_DOB)
sex = USER2_SEX
else:
user = USER3_NAME
height = USER3_HEIGHT
age = self.GetAge(USER3_DOB)
sex = USER3_SEX
lib = Xiaomi_Scale_Body_Metrics.bodyMetrics(weight, height, age, sex, 0)
message = '{'
message += '"Weight":"' + "{:.2f}".format(weight) + '"'
message += ',"BMI":"' + "{:.2f}".format(lib.getBMI()) + '"'
message += ',"Basal Metabolism":"' + "{:.2f}".format(lib.getBMR()) + '"'
message += ',"Visceral Fat":"' + "{:.2f}".format(lib.getVisceralFat()) + '"'
if hasImpedance:
lib = Xiaomi_Scale_Body_Metrics.bodyMetrics(weight, height, age, sex, int(miimpedance))
bodyscale = ['Obese', 'Overweight', 'Thick-set', 'Lack-exerscise', 'Balanced', 'Balanced-muscular', 'Skinny', 'Balanced-skinny', 'Skinny-muscular']
message += ',"Lean Body Mass":"' + "{:.2f}".format(lib.getLBMCoefficient()) + '"'
message += ',"Body Fat":"' + "{:.2f}".format(lib.getFatPercentage()) + '"'
message += ',"Water":"' + "{:.2f}".format(lib.getWaterPercentage()) + '"'
message += ',"Bone Mass":"' + "{:.2f}".format(lib.getBoneMass()) + '"'
message += ',"Muscle Mass":"' + "{:.2f}".format(lib.getMuscleMass()) + '"'
message += ',"Protein":"' + "{:.2f}".format(lib.getProteinPercentage()) + '"'
message += ',"Body Type":"' + str(bodyscale[lib.getBodyType()]) + '"'
message += ',"Metabolic Age":"' + "{:.0f}".format(lib.getMetabolicAge()) + '"'
message += ',"TimeStamp":"' + mitdatetime + '"'
message += '}'
try:
sys.stdout.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Publishing data to topic {MQTT_PREFIX + '/' + user + '/weight'}: {message}\n")
publish.single(
MQTT_PREFIX + '/' + user + '/weight',
message,
# qos=1, #Removed qos=1 as incorrect connection details will result in the client waiting for ack from broker
retain=True,
hostname=MQTT_HOST,
port=MQTT_PORT,
auth={'username':MQTT_USERNAME, 'password':MQTT_PASSWORD}
)
sys.stdout.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Data Published ...\n")
except Exception as error:
sys.stderr.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Could not publish to MQTT: {error}\n")
raise
def main():
BluetoothFailCounter = 0
while True:
try:
scanner = btle.Scanner(HCI_DEV).withDelegate(ScanProcessor())
scanner.scan(5) # Adding passive=True to try and fix issues on RPi devices
except BTLEDisconnectError as error:
sys.stderr.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - btle disconnected: {error}\n")
pass
except BTLEManagementError as error:
sys.stderr.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Bluetooth connection error: {error}\n")
if BluetoothFailCounter >= 4:
sys.stderr.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - 5+ Bluetooth connection errors. Resetting Bluetooth...\n")
cmd = 'hciconfig hci0 reset'
ps = subprocess.Popen(cmd, shell=True)
time.sleep(30)
BluetoothFailCounter = 0
else:
BluetoothFailCounter+=1
pass
except Exception as error:
sys.stderr.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Error while running the script: {error}\n")
pass
else:
BluetoothFailCounter = 0
time.sleep(TIME_INTERVAL)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,223 @@
from math import floor
import sys
from body_scales import bodyScales
class bodyMetrics:
def __init__(self, weight, height, age, sex, impedance):
self.weight = weight
self.height = height
self.age = age
self.sex = sex
self.impedance = impedance
self.scales = bodyScales(age, height, sex, weight)
# Check for potential out of boundaries
if self.height > 220:
print("Height is too high (limit: >220cm) or scale is sleeping")
sys.stderr.write('Height is over 220cm or scale is sleeping\n')
exit()
elif weight < 10 or weight > 200:
print("Weight is either too low or too high (limits: <10kg and >200kg) or scale is sleeping")
sys.stderr.write('Weight is above 10kg or below 200kg or scale is sleeping\n')
exit()
elif age > 99:
print("Age is too high (limit >99 years) or scale is sleeping")
sys.stderr.write('Age is above 99 years or scale is sleeping\n')
exit()
elif impedance > 3000:
print("Impedance is above 3000ohm or scale is sleeping")
sys.stderr.write('Impedance is above 3000ohm or scale is sleeping\n')
exit()
# Set the value to a boundary if it overflows
def checkValueOverflow(self, value, minimum, maximum):
if value < minimum:
return minimum
elif value > maximum:
return maximum
else:
return value
# Get LBM coefficient (with impedance)
def getLBMCoefficient(self):
lbm = (self.height * 9.058 / 100) * (self.height / 100)
lbm += self.weight * 0.32 + 12.226
lbm -= self.impedance * 0.0068
lbm -= self.age * 0.0542
return lbm
# Get BMR
def getBMR(self):
if self.sex == 'female':
bmr = 864.6 + self.weight * 10.2036
bmr -= self.height * 0.39336
bmr -= self.age * 6.204
else:
bmr = 877.8 + self.weight * 14.916
bmr -= self.height * 0.726
bmr -= self.age * 8.976
# Capping
if self.sex == 'female' and bmr > 2996:
bmr = 5000
elif self.sex == 'male' and bmr > 2322:
bmr = 5000
return self.checkValueOverflow(bmr, 500, 10000)
# Get fat percentage
def getFatPercentage(self):
# Set a constant to remove from LBM
if self.sex == 'female' and self.age <= 49:
const = 9.25
elif self.sex == 'female' and self.age > 49:
const = 7.25
else:
const = 0.8
# Calculate body fat percentage
LBM = self.getLBMCoefficient()
if self.sex == 'male' and self.weight < 61:
coefficient = 0.98
elif self.sex == 'female' and self.weight > 60:
coefficient = 0.96
if self.height > 160:
coefficient *= 1.03
elif self.sex == 'female' and self.weight < 50:
coefficient = 1.02
if self.height > 160:
coefficient *= 1.03
else:
coefficient = 1.0
fatPercentage = (1.0 - (((LBM - const) * coefficient) / self.weight)) * 100
# Capping body fat percentage
if fatPercentage > 63:
fatPercentage = 75
return self.checkValueOverflow(fatPercentage, 5, 75)
# Get water percentage
def getWaterPercentage(self):
waterPercentage = (100 - self.getFatPercentage()) * 0.7
if (waterPercentage <= 50):
coefficient = 1.02
else:
coefficient = 0.98
# Capping water percentage
if waterPercentage * coefficient >= 65:
waterPercentage = 75
return self.checkValueOverflow(waterPercentage * coefficient, 35, 75)
# Get bone mass
def getBoneMass(self):
if self.sex == 'female':
base = 0.245691014
else:
base = 0.18016894
boneMass = (base - (self.getLBMCoefficient() * 0.05158)) * -1
if boneMass > 2.2:
boneMass += 0.1
else:
boneMass -= 0.1
# Capping boneMass
if self.sex == 'female' and boneMass > 5.1:
boneMass = 8
elif self.sex == 'male' and boneMass > 5.2:
boneMass = 8
return self.checkValueOverflow(boneMass, 0.5 , 8)
# Get muscle mass
def getMuscleMass(self):
muscleMass = self.weight - ((self.getFatPercentage() * 0.01) * self.weight) - self.getBoneMass()
# Capping muscle mass
if self.sex == 'female' and muscleMass >= 84:
muscleMass = 120
elif self.sex == 'male' and muscleMass >= 93.5:
muscleMass = 120
return self.checkValueOverflow(muscleMass, 10 ,120)
# Get Visceral Fat
def getVisceralFat(self):
if self.sex == 'female':
if self.weight > (13 - (self.height * 0.5)) * -1:
subsubcalc = ((self.height * 1.45) + (self.height * 0.1158) * self.height) - 120
subcalc = self.weight * 500 / subsubcalc
vfal = (subcalc - 6) + (self.age * 0.07)
else:
subcalc = 0.691 + (self.height * -0.0024) + (self.height * -0.0024)
vfal = (((self.height * 0.027) - (subcalc * self.weight)) * -1) + (self.age * 0.07) - self.age
else:
if self.height < self.weight * 1.6:
subcalc = ((self.height * 0.4) - (self.height * (self.height * 0.0826))) * -1
vfal = ((self.weight * 305) / (subcalc + 48)) - 2.9 + (self.age * 0.15)
else:
subcalc = 0.765 + self.height * -0.0015
vfal = (((self.height * 0.143) - (self.weight * subcalc)) * -1) + (self.age * 0.15) - 5.0
return self.checkValueOverflow(vfal, 1 ,50)
# Get BMI
def getBMI(self):
return self.checkValueOverflow(self.weight/((self.height/100)*(self.height/100)), 10, 90)
# Get ideal weight (just doing a reverse BMI, should be something better)
def getIdealWeight(self, orig=True):
# Uses mi fit algorithm (or holtek's one)
if orig and self.sex == 'female':
return (self.height - 70) * 0.6
elif orig and self.sex == 'male':
return (self.height - 80) * 0.7
else:
return self.checkValueOverflow((22*self.height)*self.height/10000, 5.5, 198)
# Get fat mass to ideal (guessing mi fit formula)
def getFatMassToIdeal(self):
mass = (self.weight * (self.getFatPercentage() / 100)) - (self.weight * (self.scales.getFatPercentageScale()[2] / 100))
if mass < 0:
return {'type': 'to_gain', 'mass': mass*-1}
else:
return {'type': 'to_lose', 'mass': mass}
# Get protetin percentage (warn: guessed formula)
def getProteinPercentage(self, orig=True):
# Use original algorithm from mi fit (or legacy guess one)
if orig:
proteinPercentage = (self.getMuscleMass() / self.weight) * 100
proteinPercentage -= self.getWaterPercentage()
else:
proteinPercentage = 100 - (floor(self.getFatPercentage() * 100) / 100)
proteinPercentage -= floor(self.getWaterPercentage() * 100) / 100
proteinPercentage -= floor((self.getBoneMass()/self.weight*100) * 100) / 100
return self.checkValueOverflow(proteinPercentage, 5, 32)
# Get body type (out of nine possible)
def getBodyType(self):
if self.getFatPercentage() > self.scales.getFatPercentageScale()[2]:
factor = 0
elif self.getFatPercentage() < self.scales.getFatPercentageScale()[1]:
factor = 2
else:
factor = 1
if self.getMuscleMass() > self.scales.getMuscleMassScale()[1]:
return 2 + (factor * 3)
elif self.getMuscleMass() < self.scales.getMuscleMassScale()[0]:
return (factor * 3)
else:
return 1 + (factor * 3)
# Get Metabolic Age
def getMetabolicAge(self):
if self.sex == 'female':
metabolicAge = (self.height * -1.1165) + (self.weight * 1.5784) + (self.age * 0.4615) + (self.impedance * 0.0415) + 83.2548
else:
metabolicAge = (self.height * -0.7471) + (self.weight * 0.9161) + (self.age * 0.4184) + (self.impedance * 0.0517) + 54.2267
return self.checkValueOverflow(metabolicAge, 15, 80)

155
src/body_scales.py Normal file
View File

@ -0,0 +1,155 @@
class bodyScales:
def __init__(self, age, height, sex, weight, scaleType='xiaomi'):
self.age = age
self.height = height
self.sex = sex
self.weight = weight
if scaleType == 'xiaomi':
self.scaleType = 'xiaomi'
else:
self.scaleType = 'holtek'
# Get BMI scale
def getBMIScale(self):
if self.scaleType == 'xiaomi':
# Amazfit/new mi fit
#return [18.5, 24, 28]
# Old mi fit // amazfit for body figure
return [18.5, 25.0, 28.0, 32.0]
elif self.scaleType == 'holtek':
return [18.5, 25.0, 30.0]
# Get fat percentage scale
def getFatPercentageScale(self):
# The included tables where quite strange, maybe bogus, replaced them with better ones...
if self.scaleType == 'xiaomi':
scales = [
{'min': 0, 'max': 12, 'female': [12.0, 21.0, 30.0, 34.0], 'male': [7.0, 16.0, 25.0, 30.0]},
{'min': 12, 'max': 14, 'female': [15.0, 24.0, 33.0, 37.0], 'male': [7.0, 16.0, 25.0, 30.0]},
{'min': 14, 'max': 16, 'female': [18.0, 27.0, 36.0, 40.0], 'male': [7.0, 16.0, 25.0, 30.0]},
{'min': 16, 'max': 18, 'female': [20.0, 28.0, 37.0, 41.0], 'male': [7.0, 16.0, 25.0, 30.0]},
{'min': 18, 'max': 40, 'female': [21.0, 28.0, 35.0, 40.0], 'male': [11.0, 17.0, 22.0, 27.0]},
{'min': 40, 'max': 60, 'female': [22.0, 29.0, 36.0, 41.0], 'male': [12.0, 18.0, 23.0, 28.0]},
{'min': 60, 'max': 100, 'female': [23.0, 30.0, 37.0, 42.0], 'male': [14.0, 20.0, 25.0, 30.0]},
]
elif self.scaleType == 'holtek':
scales = [
{'min': 0, 'max': 21, 'female': [18, 23, 30, 35], 'male': [8, 14, 21, 25]},
{'min': 21, 'max': 26, 'female': [19, 24, 30, 35], 'male': [10, 15, 22, 26]},
{'min': 26, 'max': 31, 'female': [20, 25, 31, 36], 'male': [11, 16, 21, 27]},
{'min': 31, 'max': 36, 'female': [21, 26, 33, 36], 'male': [13, 17, 25, 28]},
{'min': 36, 'max': 41, 'female': [22, 27, 34, 37], 'male': [15, 20, 26, 29]},
{'min': 41, 'max': 46, 'female': [23, 28, 35, 38], 'male': [16, 22, 27, 30]},
{'min': 46, 'max': 51, 'female': [24, 30, 36, 38], 'male': [17, 23, 29, 31]},
{'min': 51, 'max': 56, 'female': [26, 31, 36, 39], 'male': [19, 25, 30, 33]},
{'min': 56, 'max': 100, 'female': [27, 32, 37, 40], 'male': [21, 26, 31, 34]},
]
for scale in scales:
if self.age >= scale['min'] and self.age < scale['max']:
return scale[self.sex]
# Get muscle mass scale
def getMuscleMassScale(self):
if self.scaleType == 'xiaomi':
scales = [
{'min': {'male': 170, 'female': 160}, 'female': [36.5, 42.6], 'male': [49.4, 59.5]},
{'min': {'male': 160, 'female': 150}, 'female': [32.9, 37.6], 'male': [44.0, 52.5]},
{'min': {'male': 0, 'female': 0}, 'female': [29.1, 34.8], 'male': [38.5, 46.6]},
]
elif self.scaleType == 'holtek':
scales = [
{'min': {'male': 170, 'female': 170}, 'female': [36.5, 42.5], 'male': [49.5, 59.4]},
{'min': {'male': 160, 'female': 160}, 'female': [32.9, 37.5], 'male': [44.0, 52.4]},
{'min': {'male': 0, 'female': 0}, 'female': [29.1, 34.7], 'male': [38.5, 46.5]}
]
for scale in scales:
if self.height >= scale['min'][self.sex]:
return scale[self.sex]
# Get water percentage scale
def getWaterPercentageScale(self):
if self.scaleType == 'xiaomi':
if self.sex == 'male':
return [55.0, 65.1]
elif self.sex == 'female':
return [45.0, 60.1]
elif self.scaleType == 'holtek':
return [53, 67]
# Get visceral fat scale
def getVisceralFatScale(self):
# Actually the same in mi fit/amazfit and holtek's sdk
return [10.0, 15.0]
# Get bone mass scale
def getBoneMassScale(self):
if self.scaleType == 'xiaomi':
scales = [
{'male': {'min': 75.0, 'scale': [2.0, 4.2]}, 'female': {'min': 60.0, 'scale': [1.8, 3.9]}},
{'male': {'min': 60.0, 'scale': [1.9, 4.1]}, 'female': {'min': 45.0, 'scale': [1.5, 3.8]}},
{'male': {'min': 0.0, 'scale': [1.6, 3.9]}, 'female': {'min': 0.0, 'scale': [1.3, 3.6]}},
]
for scale in scales:
if self.weight >= scale[self.sex]['min']:
return scale[self.sex]['scale']
elif self.scaleType == 'holtek':
scales = [
{'female': {'min': 60, 'optimal': 2.5}, 'male': {'min': 75, 'optimal': 3.2}},
{'female': {'min': 45, 'optimal': 2.2}, 'male': {'min': 69, 'optimal': 2.9}},
{'female': {'min': 0, 'optimal': 1.8}, 'male': {'min': 0, 'optimal': 2.5}}
]
for scale in scales:
if self.weight >= scale[self.sex]['min']:
return [scale[self.sex]['optimal']-1, scale[self.sex]['optimal']+1]
# Get BMR scale
def getBMRScale(self):
if self.scaleType == 'xiaomi':
coefficients = {
'male': {30: 21.6, 50: 20.07, 100: 19.35},
'female': {30: 21.24, 50: 19.53, 100: 18.63}
}
elif self.scaleType == 'holtek':
coefficients = {
'female': {12: 34, 15: 29, 17: 24, 29: 22, 50: 20, 120: 19},
'male': {12: 36, 15: 30, 17: 26, 29: 23, 50: 21, 120: 20}
}
for age, coefficient in coefficients[self.sex].items():
if self.age < age:
return [self.weight * coefficient]
# Get protein scale (hardcoded in mi fit)
def getProteinPercentageScale(self):
# Actually the same in mi fit and holtek's sdk
return [16, 20]
# Get ideal weight scale (BMI scale converted to weights)
def getIdealWeightScale(self):
scale = []
for bmiScale in self.getBMIScale():
scale.append((bmiScale*self.height)*self.height/10000)
return scale
# Get Body Score scale
def getBodyScoreScale(self):
# very bad, bad, normal, good, better
return [50.0, 60.0, 80.0, 90.0]
# Return body type scale
def getBodyTypeScale(self):
return ['obese', 'overweight', 'thick-set', 'lack-exerscise', 'balanced', 'balanced-muscular', 'skinny', 'balanced-skinny', 'skinny-muscular']

181
src/body_score.py Normal file
View File

@ -0,0 +1,181 @@
# Reverse engineered from amazfit's app (also known as Mi Fit)
from body_scales import bodyScales
class bodyScore:
def __init__(self, age, sex, height, weight, bmi, bodyfat, muscle, water, visceral_fat, bone, basal_metabolism, protein):
self.age = age
self.sex = sex
self.height = height
self.weight = weight
self.bmi = bmi
self.bodyfat = bodyfat
self.muscle = muscle
self.water = water
self.visceral_fat = visceral_fat
self.bone = bone
self.basal_metabolism = basal_metabolism
self.protein = protein
self.scales = bodyScales(age, height, sex, weight)
def getBodyScore(self):
score = 100
score -= self.getBmiDeductScore()
score -= self.getBodyFatDeductScore()
score -= self.getMuscleDeductScore()
score -= self.getWaterDeductScore()
score -= self.getVisceralFatDeductScore()
score -= self.getBoneDeductScore()
score -= self.getBasalMetabolismDeductScore()
if self.protein:
score -= self.getProteinDeductScore()
return score
def getMalus(self, data, min_data, max_data, max_malus, min_malus):
result = ((data - max_data) / (min_data - max_data)) * float(max_malus - min_malus)
if result >= 0.0:
return result
return 0.0
def getBmiDeductScore(self):
if not self.height >= 90:
# "BMI is not reasonable
return 0.0
bmi_low = 15.0
bmi_verylow = 14.0
bmi_normal = 18.5
bmi_overweight = 28.0
bmi_obese = 32.0
fat_scale = self.scales.getFatPercentageScale()
# Perfect range (bmi >= 18.5 and bodyfat not high for adults, bmi >= 15.0 for kids
if self.bmi >= 18.5 and self.age >= 18 and self.bodyfat < fat_scale[2]:
return 0.0
elif self.bmi >= bmi_verylow and self.age < 18 and self.bodyfat < fat_scale[2]:
return 0.0
# Extremely skinny (bmi < 14)
elif self.bmi <= bmi_verylow:
return 30.0
# Too skinny (bmi between 14 and 15)
elif self.bmi > bmi_verylow and self.bmi < bmi_low:
return self.getMalus(self.bmi, bmi_verylow, bmi_low, 30, 15) + 15.0
# Skinny (for adults, between 15 and 18.5)
elif self.bmi >= bmi_low and self.bmi < bmi_normal and self.age >= 18:
return self.getMalus(self.bmi, 15.0, 18.5, 15, 5) + 5.0
# Normal or high bmi but too much bodyfat
elif ((self.bmi >= bmi_low and self.age < 18) or (self.bmi >= bmi_normal and self.age >= 18)) and self.bodyfat >= fat_scale[2]:
# Obese
if self.bmi >= bmi_obese:
return 10.0
# Overweight
if self.bmi > bmi_overweight:
return self.getMalus(self.bmi, 28.0, 25.0, 5, 10) + 5.0
else:
return 0.0
def getBodyFatDeductScore(self):
scale = self.scales.getFatPercentageScale()
if self.sex == 'male':
best = scale[2] - 3.0
elif self.sex == 'female':
best = scale[2] - 2.0
# Slighly low in fat or low part or normal fat
if self.bodyfat >= scale[0] and self.bodyfat < best:
return 0.0
elif self.bodyfat >= scale[3]:
return 20.0
else:
# Sightly high body fat
if self.bodyfat < scale[3]:
return self.getMalus(self.bodyfat, scale[3], scale[2], 20, 10) + 10.0
# High part of normal fat
elif self.bodyfat <= scale[2]:
return self.getMalus(self.bodyfat, scale[2], best, 3, 9) + 3.0
# Very low in fat
elif self.bodyfat < scale[0]:
return self.getMalus(self.bodyfat, 1.0, scale[0], 3, 10) + 3.0
def getMuscleDeductScore(self):
scale = self.scales.getMuscleMassScale()
# For some reason, there's code to return self.calculate(muscle, normal[0], normal[0]+2.0, 3, 5) + 3.0
# if your muscle is between normal[0] and normal[0] + 2.0, but it's overwritten with 0.0 before return
if self.muscle >= scale[0]:
return 0.0
elif self.muscle < (scale[0] - 5.0):
return 10.0
else:
return self.getMalus(self.muscle, scale[0] - 5.0, scale[0], 10, 5) + 5.0
# No malus = normal or good; maximum malus (10.0) = less than normal-5.0;
# malus = between 5 and 10, on your water being between normal-5.0 and normal
def getWaterDeductScore(self):
scale = self.scales.getWaterPercentageScale()
if self.water >= scale[0]:
return 0.0
elif self.water <= (scale[0] - 5.0):
return 10.0
else:
return self.getMalus(self.water, scale[0] - 5.0, scale[0], 10, 5) + 5.0
# No malus = normal; maximum malus (15.0) = very high; malus = between 10 and 15
# with your visceral fat in your high range
def getVisceralFatDeductScore(self):
scale = self.scales.getVisceralFatScale()
if self.visceral_fat < scale[0]:
# For some reason, the original app would try to
# return 3.0 if vfat == 8 and 5.0 if vfat == 9
# but i's overwritten with 0.0 anyway before return
return 0.0
elif self.visceral_fat >= scale[1]:
return 15.0
else:
return self.getMalus(self.visceral_fat, scale[1], scale[0], 15, 10) + 10.0
def getBoneDeductScore(self):
scale = self.scales.getBoneMassScale()
if self.bone >= scale[0]:
return 0.0
elif self.bone <= (scale[0] - 0.3):
return 10.0
else:
return self.getMalus(self.bone, scale[0] - 0.3, scale[0], 10, 5) + 5.0
def getBasalMetabolismDeductScore(self):
# Get normal BMR
normal = self.scales.getBMRScale()[0]
if self.basal_metabolism >= normal:
return 0.0
elif self.basal_metabolism <= (normal - 300):
return 6.0
else:
# It's really + 5.0 in the app, but it's probably a mistake, should be 3.0
return self.getMalus(self.basal_metabolism, normal - 300, normal, 6, 3) + 5.0
# Get protein percentage malus
def getProteinDeductScore(self):
# low: 10,16; normal: 16,17
# Check limits
if self.protein > 17.0:
return 0.0
elif self.protein < 10.0:
return 10.0
else:
# Return values for low proteins or normal proteins
if self.protein <= 16.0:
return self.getMalus(self.protein, 10.0, 16.0, 10, 5) + 5.0
elif self.protein <= 17.0:
return self.getMalus(self.protein, 16.0, 17.0, 5, 3) + 3.0

2
src/requirements.txt Normal file
View File

@ -0,0 +1,2 @@
bluepy==1.3.0
paho-mqtt==1.5.0

24
src/wrapper.sh Normal file
View File

@ -0,0 +1,24 @@
#!/bin/bash
export MISCALE_MAC=00:00:00:00:00:00 # Mac address of your scale
export MQTT_PREFIX=miScale
export USER1_GT=70 # If the weight is greater than this number, we'll assume that we're weighing User #1
export USER1_SEX=male
export USER1_NAME=Jo # Name of the user
export USER1_HEIGHT=175 # Height (in cm) of the user
export USER1_DOB=1990-01-01 # DOB (in yyyy-mm-dd format)
export USER2_LT=35 # If the weight is less than this number, we'll assume that we're weighing User #2
export USER2_SEX=female
export USER2_NAME=Sarah # Name of the user
export USER2_HEIGHT=95 # Height (in cm) of the user
export USER2_DOB=1990-01-01 # DOB (in yyyy-mm-dd format)
export USER3_SEX=female
export USER3_NAME=Missy # Name of the user
export USER3_HEIGHT=150 # Height (in cm) of the user
export USER3_DOB=1990-01-01 # DOB (in yyyy-mm-dd format)
MY_PWD="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
python3 $MY_PWD/Xiaomi_Scale.py