Add support for docker containers, mqtt prefix, environment variables for config, and pip requirements

This commit is contained in:
David 2019-09-02 14:12:33 +10:00
parent 9d55f12105
commit a54c3b133e
10 changed files with 219 additions and 44 deletions

183
src/Xiaomi_Scale.py Normal file
View file

@ -0,0 +1,183 @@
#############################################################################################
# Code to read weight measurements fom Xiaomi Scale V2
# (Framework is prsent to also read from Xiaomi Scale V1, though I do not own one to test so code has not been maintained)
# Must be executed with Python 3 else body measurements are incorrect.
# Must be executed as root, therefore best to schedule via crontab every 5 min (so as not to drain the battery):
# */5 * * * * python3 /path-to-script/Xiaomi_Scale.py
# Multi user possible as long as weitghs do not overlap, see lines 117-131
#
# Thanks to @syssi (https://gist.github.com/syssi/4108a54877406dc231d95514e538bde9) and @prototux (https://github.com/wiecosystem/Bluetooth) for their initial code
#
# Make sure you set your MQTT credentials below and user logic/data through the environment variables
#
#############################################################################################
#!/usr/bin/python
# -*- coding: utf-8 -*-
from __future__ import print_function
import argparse
import binascii
import time
import os
import sys
from bluepy import btle
import paho.mqtt.client as mqtt
from datetime import datetime
import Xiaomi_Scale_Body_Metrics
# Configuraiton...
MISCALE_MAC = os.getenv('MISCALE_MAC', '')
MQTT_USERNAME = os.getenv('MQTT_USERNAME', '')
MQTT_PASSWORD = os.getenv('MQTT_PASSWORD', '')
MQTT_HOST = os.getenv('MQTT_HOST', '127.0.0.1')
MQTT_PORT = os.getenv('MQTT_PORT', 1883)
MQTT_TIMEOUT = os.getenv('MQTT_TIMEOUT', 60)
MQTT_PREFIX = os.getenv('MQTT_PREFIX', '')
# 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)
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):
self.mqtt_client = None
self.connected = False
self._start_client()
def handleDiscovery(self, dev, isNewDev, isNewData):
if dev.addr == MISCALE_MAC.lower() and isNewDev:
# print (' Device: %s (%s), %d dBm %s. ' %
# (
# ANSI_WHITE + dev.addr + ANSI_OFF,
# dev.addrType,
# dev.rssi,
# ('' if dev.connectable else '(not connectable)'))
# , end='')
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:
print('')
self._publish(round(measured, 2), unit, "", "")
else:
print("Scale is sleeping.")
### Xiaomi V2 Scale ###
if data.startswith('1b18') and sdid == 22:
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:
print('')
self._publish(round(measured, 2), unit, str(mitdatetime), miimpedance)
else:
print("Scale is sleeping.")
if not dev.scanData:
print ('\t(no data)')
print
def _start_client(self):
self.mqtt_client = mqtt.Client()
self.mqtt_client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
def _on_connect(client, _, flags, return_code):
self.connected = True
#print("MQTT connection returned result: %s" % mqtt.connack_string(return_code))
self.mqtt_client.on_connect = _on_connect
self.mqtt_client.connect(MQTT_HOST, MQTT_PORT, MQTT_TIMEOUT)
self.mqtt_client.loop_start()
def _publish(self, weight, unit, mitdatetime, miimpedance):
if not self.connected:
raise Exception('not connected to MQTT server')
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 miimpedance:
lib = Xiaomi_Scale_Body_Metrics.bodyMetrics(weight, height, age, sex, int(miimpedance))
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()) + '"'
self.mqtt_client.publish(MQTT_PREFIX + '/' + user, weight, qos=1, retain=True)
message += ',"TimeStamp":"' + mitdatetime + '"'
message += '}'
self.mqtt_client.publish(MQTT_PREFIX + '/' + user + '/weight', message, qos=1, retain=True)
print('\tSent data to topic %s: %s' % (MQTT_PREFIX + '/' + user + '/weight', message))
def main():
# while(True):
scanner = btle.Scanner().withDelegate(ScanProcessor())
devices = scanner.scan(5)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,277 @@
from math import floor
# Reverse engineered from the Mi Body Composition Scale's library, could also be used on some other scales such as iHealth
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
# Check for potential out of boundaries
if self.height > 220:
raise Exception("Height is too high (limit: >220cm)")
elif weight < 10 or weight > 200:
raise Exception("Weight is either too low or too high (limits: <10kg and >200kg)")
elif age > 99:
raise Exception("Age is too high (limit >99 years)")
elif impedance > 3000:
raise Exception("Impedance is too high (limit >3000ohm)")
# 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 BMR scale
def getBMRScale(self):
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]
break
# 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 fat percentage scale
def getFatPercentageScale(self):
# The included tables where quite strange, maybe bogus, replaced them with better ones...
scales = [
{'min': 0, 'max': 20, 'female': [18, 23, 30, 35], 'male': [8, 14, 21, 25]},
{'min': 21, 'max': 25, 'female': [19, 24, 30, 35], 'male': [10, 15, 22, 26]},
{'min': 26, 'max': 30, 'female': [20, 25, 31, 36], 'male': [11, 16, 21, 27]},
{'min': 31, 'max': 35, 'female': [21, 26, 33, 36], 'male': [13, 17, 25, 28]},
{'min': 46, 'max': 40, 'female': [22, 27, 34, 37], 'male': [15, 20, 26, 29]},
{'min': 41, 'max': 45, 'female': [23, 28, 35, 38], 'male': [16, 22, 27, 30]},
{'min': 46, 'max': 50, 'female': [24, 30, 36, 38], 'male': [17, 23, 29, 31]},
{'min': 51, 'max': 55, '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 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 water percentage scale
def getWaterPercentageScale(self):
return [53, 67]
# 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 bone mass scale
def getBoneMassScale(self):
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 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 muscle mass scale
def getMuscleMassScale(self):
scales = [
{'min': 170, 'female': [36.5, 42.5], 'male': [49.5, 59.4]},
{'min': 160, 'female': [32.9, 37.5], 'male': [44.0, 52.4]},
{'min': 0, 'female': [29.1, 34.7], 'male': [38.5, 46.5]}
]
for scale in scales:
if self.height >= scale['min']:
return scale[self.sex]
# 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 visceral fat scale
def getVisceralFatScale(self):
return [10, 15]
# Get BMI
def getBMI(self):
return self.checkValueOverflow(self.weight/((self.height/100)*(self.height/100)), 10, 90)
# Get BMI scale
def getBMIScale(self):
# Replaced library's version by mi fit scale, it seems better
return [18.5, 25, 28, 32]
# Get ideal weight (just doing a reverse BMI, should be something better)
def getIdealWeight(self):
return self.checkValueOverflow((22*self.height)*self.height/10000, 5.5, 198)
# 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 fat mass to ideal (guessing mi fit formula)
def getFatMassToIdeal(self):
mass = (self.weight * (self.getFatPercentage() / 100)) - (self.weight * (self.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):
proteinPercentage = 100 - (floor(self.getFatPercentage() * 100) / 100)
proteinPercentage -= floor(self.getWaterPercentage() * 100) / 100
proteinPercentage -= floor((self.getBoneMass()/self.weight*100) * 100) / 100
return proteinPercentage
# Get protein scale (hardcoded in mi fit)
def getProteinPercentageScale(self):
return [16, 20]
# Get body type (out of nine possible)
def getBodyType(self):
if self.getFatPercentage() > self.getFatPercentageScale()[2]:
factor = 0
elif self.getFatPercentage() < self.getFatPercentageScale()[1]:
factor = 2
else:
factor = 1
if self.getMuscleMass() > self.getMuscleMassScale()[1]:
return 2 + (factor * 3)
elif self.getMuscleMass() < self.getMuscleMassScale()[0]:
return (factor * 3)
else:
return 1 + (factor * 3)
# Return body type scale
def getBodyTypeScale(self):
return ['obese', 'overweight', 'thick-set', 'lack-exerscise', 'balanced', 'balanced-muscular', 'skinny', 'balanced-skinny', 'skinny-muscular']

2
src/requirements.txt Normal file
View file

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

32
src/wrapper.sh Normal file
View file

@ -0,0 +1,32 @@
#!/bin/bash
export MISCALE_MAC=00:00:00:00:00:00 # Mac address of your scale
export MQTT_PREFIX=miScale
#export MQTT_HOST=<YOUR-IP> # MQTT Server (defaults to 127.0.0.1)
#export MQTT_USERNAME= # Username for MQTT server (comment out if not required)
#export MQTT_PASSWORD= # Password for MQTT (comment out if not required)
#export MQTT_PORT= # Defaults to 1883
#export MQTT_TIMEOUT=30 # Defaults to 60
# Auto-gender selection/config -- This is used to create the calculations such as BMI, Water/Bone Mass etc...
# Multi user possible as long as weitghs do not overlap!
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