commit b700f506cf385cb81ab053a2b01ee4560f024a6c Author: lolorpi Date: Tue Jun 30 19:18:55 2020 +0100 initial commit diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..753033c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..73987ed --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c3f6dee --- /dev/null +++ b/CHANGELOG.md @@ -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). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d8e9481 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..958e460 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..191d1a4 --- /dev/null +++ b/README.md @@ -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 diff --git a/Screenshots/addon.png b/Screenshots/addon.png new file mode 100644 index 0000000..717cd6a Binary files /dev/null and b/Screenshots/addon.png differ diff --git a/Screenshots/addon_store.png b/Screenshots/addon_store.png new file mode 100644 index 0000000..9e50f72 Binary files /dev/null and b/Screenshots/addon_store.png differ diff --git a/config.json b/config.json new file mode 100644 index 0000000..576051c --- /dev/null +++ b/config.json @@ -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" + } +} \ No newline at end of file diff --git a/dockerscripts/cmd.sh b/dockerscripts/cmd.sh new file mode 100644 index 0000000..5629f8e --- /dev/null +++ b/dockerscripts/cmd.sh @@ -0,0 +1,2 @@ +set -e +python3 -u /opt/miscale/Xiaomi_Scale.py \ No newline at end of file diff --git a/dockerscripts/entrypoint.sh b/dockerscripts/entrypoint.sh new file mode 100644 index 0000000..4ba1611 --- /dev/null +++ b/dockerscripts/entrypoint.sh @@ -0,0 +1,3 @@ +#!/bin/sh +set -e +exec "$@" diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..0a115a0 Binary files /dev/null and b/icon.png differ diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..2e9e697 Binary files /dev/null and b/logo.png differ diff --git a/src/Xiaomi_Scale.py b/src/Xiaomi_Scale.py new file mode 100644 index 0000000..85b04c5 --- /dev/null +++ b/src/Xiaomi_Scale.py @@ -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() diff --git a/src/Xiaomi_Scale_Body_Metrics.py b/src/Xiaomi_Scale_Body_Metrics.py new file mode 100644 index 0000000..059e1ee --- /dev/null +++ b/src/Xiaomi_Scale_Body_Metrics.py @@ -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) diff --git a/src/body_scales.py b/src/body_scales.py new file mode 100644 index 0000000..2f265c2 --- /dev/null +++ b/src/body_scales.py @@ -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'] + diff --git a/src/body_score.py b/src/body_score.py new file mode 100644 index 0000000..be16d97 --- /dev/null +++ b/src/body_score.py @@ -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 diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..e5511cf --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1,2 @@ +bluepy==1.3.0 +paho-mqtt==1.5.0 \ No newline at end of file diff --git a/src/wrapper.sh b/src/wrapper.sh new file mode 100644 index 0000000..ba61e74 --- /dev/null +++ b/src/wrapper.sh @@ -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 \ No newline at end of file