diff --git a/README.md b/README.md index 87cc01e..44d3d48 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Etekcity ESF-551 BLE -This package provides a basic unofficial interface for interacting with [Etekcity ESF-551 Smart Fitness Scale](https://etekcity.com/products/smart-fitness-scale-esf551) using Bluetooth Low Energy (BLE). It allows you to easily connect to the scale, receive weight and impedance measurements, and manage the display unit settings. It is a very basic library, providing access to just the most fundamental functionality of the scale. +This package provides a basic unofficial interface for interacting with [Etekcity ESF-551 Smart Fitness Scale](https://etekcity.com/products/smart-fitness-scale-esf551) using Bluetooth Low Energy (BLE). It allows you to easily connect to the scale, receive weight and impedance measurements, manage the display unit settings, and calculate various body metrics. It has only been tested on the ESF-551 model. I have no idea whether it might also work with some other bluetooth bathroom scale models from Etekcity. If you try it with a different model, please let me know whether it works or not. @@ -9,7 +9,6 @@ It has only been tested on the ESF-551 model. I have no idea whether it might al [![Buy Me A Coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/ronnnnnnn) - ## Installation Install the package using pip: @@ -31,15 +30,40 @@ from etekcity_esf551_ble import ( EtekcitySmartFitnessScale, ScaleData, WeightUnit, + BodyMetrics, + Sex, ) async def main(): def notification_callback(data: ScaleData): - print(f"Weight: {data.measurements[WEIGHT_KEY]} KG") + print(f"Weight: {data.measurements[WEIGHT_KEY]} kg") print(f"Display Unit: {data.display_unit.name}") if IMPEDANCE_KEY in data.measurements: print(f"Impedance: {data.measurements[IMPEDANCE_KEY]} Ω") - + + # Calculate body metrics + # Note: Replace with your actual height, age and sex + body_metrics = BodyMetrics( + weight_kg=data.measurements[WEIGHT_KEY], + height_m=1.75, # Example height + age=30, # Example age + sex=Sex.Male, # Example sex + impedance=data.measurements[IMPEDANCE_KEY] + ) + print(f"Body Mass Index: {body_metrics.body_mass_index:.2f}") + print(f"Body Fat Percentage: {body_metrics.body_fat_percentage:.1f}%") + print(f"Fat-Free Weight: {body_metrics.fat_free_weight:.2f} kg") + print(f"Subcutaneous Fat Percentage: {body_metrics.subcutaneous_fat_percentage:.1f}%") + print(f"Visceral Fat Value: {body_metrics.visceral_fat_value}") + print(f"Body Water Percentage: {body_metrics.body_water_percentage:.1f}%") + print(f"Basal Metabolic Rate: {body_metrics.basal_metabolic_rate} calories") + print(f"Skeletal Muscle Percentage: {body_metrics.skeletal_muscle_percentage:.1f}%") + print(f"Muscle Mass: {body_metrics.muscle_mass:.2f} kg") + print(f"Bone Mass: {body_metrics.bone_mass:.2f} kg") + print(f"Protein Percentage: {body_metrics.protein_percentage:.1f}%") + print(f"Metabolic Age: {body_metrics.metabolic_age} years") + + # Replace XX:XX:XX:XX:XX:XX with your scale's Bluetooth address scale = EtekcitySmartFitnessScale("XX:XX:XX:XX:XX:XX", notification_callback) scale.display_unit = WeightUnit.KG # Set display unit to kilograms @@ -49,6 +73,7 @@ async def main(): asyncio.run(main()) ``` +For a real-life usage example of this library, check out the [Etekcity Fitness Scale BLE Integration for Home Assistant](https://github.com/ronnnnnnnnnnnnn/etekcity_fitness_scale_ble). ## API Reference @@ -88,6 +113,40 @@ A dataclass containing scale measurement data: - `display_unit`: Current display unit (concerns only the weight as displayed on the scale, the measurement itself is always provided by the API in kilograms) - `measurements`: Dictionary of measurements (currently supports: weight in kilograms and impedance in ohms) +### `BodyMetrics` + +A class for calculating various body composition metrics based on height, age, sex, and the weight and impedance as measured by the scale, similar to the metrics calculated and shown in the VeSync app. Note that currently "Athlete Mode" is not supported. + +#### Methods: + +- `__init__(self, weight_kg: float, height_m: float, age: int, sex: Sex, impedance: int)` + +#### Properties: + +- `body_mass_index`: Body Mass Index (BMI) +- `body_fat_percentage`: Estimated body fat percentage +- `fat_free_weight`: Weight of non-fat body mass in kg +- `subcutaneous_fat_percentage`: Estimated subcutaneous fat percentage +- `visceral_fat_value`: Estimated visceral fat level (unitless) +- `body_water_percentage`: Estimated body water percentage +- `basal_metabolic_rate`: Estimated basal metabolic rate in calories +- `skeletal_muscle_percentage`: Estimated skeletal muscle percentage +- `muscle_mass`: Estimated muscle mass in kg +- `bone_mass`: Estimated bone mass in kg +- `protein_percentage`: Estimated protein percentage +- `weight_score`: Calculated weight score (0-100) +- `fat_score`: Calculated fat score (0-100) +- `bmi_score`: Calculated BMI score (0-100) +- `health_score`: Overall health score based on other metrics (0-100) +- `metabolic_age`: Estimated metabolic age in years + +### `Sex` + +An enum representing biological sex for body composition calculations: + +- `Sex.Male` +- `Sex.Female` + ## Compatibility diff --git a/src/etekcity_esf551_ble/__init__.py b/src/etekcity_esf551_ble/__init__.py index 30663c8..87b1491 100644 --- a/src/etekcity_esf551_ble/__init__.py +++ b/src/etekcity_esf551_ble/__init__.py @@ -1,6 +1,7 @@ +from ._version import __version__ +from .body_metrics import BodyMetrics, Sex from .const import IMPEDANCE_KEY, WEIGHT_KEY from .parser import EtekcitySmartFitnessScale, ScaleData, WeightUnit -from ._version import __version__ __all__ = [ "EtekcitySmartFitnessScale", @@ -8,4 +9,6 @@ "ScaleData", "IMPEDANCE_KEY", "WEIGHT_KEY", + "BodyMetrics", + "Sex", ] diff --git a/src/etekcity_esf551_ble/_version.py b/src/etekcity_esf551_ble/_version.py index 283b03a..d3ec452 100644 --- a/src/etekcity_esf551_ble/_version.py +++ b/src/etekcity_esf551_ble/_version.py @@ -1 +1 @@ -__version__ = "0.1.7" \ No newline at end of file +__version__ = "0.2.0" diff --git a/src/etekcity_esf551_ble/body_metrics.py b/src/etekcity_esf551_ble/body_metrics.py new file mode 100644 index 0000000..fd7a9ac --- /dev/null +++ b/src/etekcity_esf551_ble/body_metrics.py @@ -0,0 +1,302 @@ +from enum import IntEnum +from functools import cached_property +from math import floor + + +class Sex(IntEnum): + Male = 0 + Female = 1 + +class BodyMetrics: + def __init__(self, weight_kg: float, height_m: float, age: int, sex: Sex, impedance: int): + self.weight = weight_kg + self.height = height_m + self.age = age + self.sex = sex + self.impedance = impedance + + @cached_property + def body_mass_index(self) -> float: + """ + Calculate Body Mass Index (BMI). + + BMI is a measure of body fat based on height and weight. + + Returns: + float: The calculated BMI value. + """ + return floor(self.weight / (self.height ** 2) * 100) / 100 + + @cached_property + def body_fat_percentage(self) -> float: + """ + Calculate Body Fat Percentage (BFP). + + BFP is the total mass of fat divided by total body mass, multiplied by 100. + + Returns: + float: The calculated BFP value. + """ + age_factor = [0.103, 0.097] + bmi_factor = [1.524, 1.545] + constant = [22, 12.7] + + bfp = floor((age_factor[self.sex] * self.age + + bmi_factor[self.sex] * self.body_mass_index - + 500/self.impedance - constant[self.sex]) * 10) / 10 + return max(5, min(75, bfp)) + + @cached_property + def fat_free_weight(self) -> float: + """ + Calculate Fat-Free Weight (FFW). + + FFW is the difference between total body weight and body fat weight. + + Returns: + float: The calculated FFW value in kg. + """ + return round(self.weight * (1 - self.body_fat_percentage/100), 2) + + @cached_property + def subcutaneous_fat_percentage(self) -> float: + """ + Calculate Subcutaneous Fat Percentage. + + Subcutaneous Fat is the fat that lies just beneath the skin. + + Returns: + float: The calculated subcutaneous fat percentage value. + """ + bfp_factor = [0.965, 0.983] + vfv_factor = [0.22, 0.303] + return round(bfp_factor[self.sex] * self.body_fat_percentage - + vfv_factor[self.sex] * self.visceral_fat_value, 1) + + @cached_property + def visceral_fat_value(self) -> int: + """ + Calculate Visceral Fat Value. + + Visceral Fat Value is a unitless measure of the level of fat stored in the abdominal cavity. + + Returns: + int: The calculated visceral fat value, between 1 and 30. + """ + bmi_factor = [0.8666, 0.8895] + bfp_factor = [0.0082, 0.0943] + fat_factor = [0.026, -0.0534] + constant = [14.2692, 16.215] + vfv = int(bmi_factor[self.sex] * self.body_mass_index + + bfp_factor[self.sex] * self.body_fat_percentage + + fat_factor[self.sex] * (self.weight - self.fat_free_weight) - + constant[self.sex]) + return max(1, min(30, vfv)) + + @cached_property + def body_water_percentage(self) -> float: + """ + Calculate Body Water Percentage (BWP). + + BWP is the total amount of water in the body as a percentage of total weight. + + Returns: + float: The calculated BWP value. + """ + ff1_factor = [0.05, 0.06] + ff2_factor = [0.76, 0.73] + ff1 = max(1, ff1_factor[self.sex] * self.fat_free_weight) + bwp = round(ff2_factor[self.sex] * (self.fat_free_weight - ff1) / self.weight * 100, 1) + return max(10, min(80, bwp)) + + @cached_property + def basal_metabolic_rate(self) -> int: + """ + Calculate Basal Metabolic Rate (BMR). + + BMR is the number of calories required to keep your body functioning at rest. + + Returns: + int: The calculated BMR value. + """ + bmr = int(self.fat_free_weight * 21.6 + 370) + return max(900, min(2500, bmr)) + + @cached_property + def skeletal_muscle_percentage(self) -> float: + """ + Calculate Skeletal Muscle Percentage. + + Skeletal muscle is the muscle tissue directly connected to bones. + + Returns: + float: The calculated skeletal muscle percentage value. + """ + ff1_factor = [0.05, 0.06] + ff2_factor = [0.68, 0.62] + ff1 = max(1, ff1_factor[self.sex] * self.fat_free_weight) + return round(ff2_factor[self.sex] * (self.fat_free_weight - ff1) / self.weight * 100, 1) + + @cached_property + def muscle_mass(self) -> float: + """ + Calculate Muscle Mass. + + Returns: + float: The calculated muscle mass value in kg. + """ + ffw_factor = [0.05, 0.06] + ff = max(1, ffw_factor[self.sex] * self.fat_free_weight) + return round(self.fat_free_weight - ff, 2) + + @cached_property + def bone_mass(self) -> float: + """ + Calculate Bone Mass. + + Bone mass is the total mass of the bones in the body. + + Returns: + float: The calculated Bone Mass value in kg. + """ + ffw_factor = [0.05, 0.06] + return max(1, round(ffw_factor[self.sex] * self.fat_free_weight, 2)) + + @cached_property + def protein_percentage(self) -> float: + """ + Calculate Protein Percentage. + + Protein percentage is the percentage of total body weight that is made up of proteins. + + Returns: + float: The calculated protein percentage value. + """ + bfp_factor = [1, 1.05] + bpp = round(100 - bfp_factor[self.sex] * self.body_fat_percentage - + self.bone_mass / self.weight * 100 - self.body_water_percentage, 1) + return max(5, bpp) + + @cached_property + def weight_score(self) -> int: + """ + Calculate Weight Score. + + Weight Score is a measure of how close the person's weight is to their ideal weight. + + Returns: + int: The calculated Weight Score, ranging from 0 to 100. + """ + height_factor = [100, 137] + constant = [80, 110] + factor = [0.7, 0.45] + res = factor[self.sex] * (height_factor[self.sex] * self.height - constant[self.sex]) + if res <= self.weight: + if res * 1.3 < self.weight: + return 50 + return int(100 - 50 * (self.weight - res) / (0.3 * res)) + if res * 0.7 < self.weight: + return int(100 - 50 * (res - self.weight) / (0.3 * res)) + for x in range(6): + if res * x / 10 > self.weight: + return x * 10 + return 0 + + @cached_property + def fat_score(self) -> int: + """ + Calculate Fat Score. + + Fat Score is a measure of how close the person's body fat percentage is to the ideal range. + + Returns: + int: The calculated Fat Score, ranging from 0 to 100. + """ + constant = [16, 26] + if constant[self.sex] < self.body_fat_percentage: + if self.body_fat_percentage >= 45: + return 50 + return int(100 - 50 * (self.body_fat_percentage - constant[self.sex]) / (45 - constant[self.sex])) + return int(100 - 50 * (constant[self.sex] - self.body_fat_percentage) / (constant[self.sex] - 5)) + + @cached_property + def bmi_score(self) -> int: + """ + Calculate BMI Score. + + BMI Score is a measure of how close the person's BMI is to the ideal range. + + Returns: + int: The calculated BMI Score. + """ + if self.body_mass_index >= 22: + if self.body_mass_index >= 35: + return 50 + return int(100 - 3.85 * (self.body_mass_index - 22)) + if self.body_mass_index >= 15: + return int(100 - 3.85 * (22 - self.body_mass_index)) + if self.body_mass_index >= 10: + return 40 + if self.body_mass_index >= 5: + return 30 + return 20 + + @cached_property + def health_score(self) -> int: + """ + Calculate Health Score. + + Health Score is an overall measure of body composition health based on weight, fat, and BMI scores. + + Returns: + int: The calculated Health Score, ranging from 0 to 100. + """ + return (self.weight_score + self.fat_score + self.bmi_score) // 3 + + @cached_property + def metabolic_age(self) -> int: + """ + Calculate Metabolic Age. + + Metabolic Age is an estimate of the body's metabolic rate compared to average values. + + Returns: + int: The calculated Metabolic Age, with a minimum of 18. + """ + if self.health_score < 50: + age_adjustment_factor = 0 + elif self.health_score < 60: + age_adjustment_factor = 1 + elif self.health_score < 65: + age_adjustment_factor = 2 + elif self.health_score < 68: + age_adjustment_factor = 3 + elif self.health_score < 70: + age_adjustment_factor = 4 + elif self.health_score < 73: + age_adjustment_factor = 5 + elif self.health_score < 75: + age_adjustment_factor = 6 + elif self.health_score < 80: + age_adjustment_factor = 7 + elif self.health_score < 85: + age_adjustment_factor = 8 + elif self.health_score < 88: + age_adjustment_factor = 9 + elif self.health_score < 90: + age_adjustment_factor = 10 + elif self.health_score < 93: + age_adjustment_factor = 11 + elif self.health_score < 95: + age_adjustment_factor = 12 + elif self.health_score < 97: + age_adjustment_factor = 13 + elif self.health_score < 98: + age_adjustment_factor = 14 + elif self.health_score < 99: + age_adjustment_factor = 15 + else: + age_adjustment_factor = 16 + + return max(18, self.age + 8 - age_adjustment_factor) + \ No newline at end of file