diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index e9caa7b..1b79b6d 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -1,5 +1,10 @@ default_config: +logger: + default: info + logs: + custom_components.bodymiscale: debug + # Example configuration.yaml entry input_number: weight: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a554e68..0db5d76 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,7 +7,7 @@ "ryanluker.vscode-coverage-gutters", "ms-python.vscode-pylance" ], - "image": "ghcr.io/ludeeus/devcontainer/integration:stable", + "image": "ghcr.io/ludeeus/devcontainer/integration", "name": "Bodymiscale Development", "postCreateCommand": "container install && pip install --ignore-installed -r requirements.txt", "settings": { diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7dfd232..1274f6f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,12 +5,12 @@ ci: repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.32.1 + rev: v2.34.0 hooks: - id: pyupgrade args: [--py39-plus] - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 22.6.0 hooks: - id: black args: @@ -41,7 +41,7 @@ repos: hooks: - id: isort - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.2.0 + rev: v4.3.0 hooks: - id: check-executables-have-shebangs - id: check-merge-conflict @@ -53,11 +53,11 @@ repos: - --fix=lf stages: [manual] - repo: https://github.com/pre-commit/mirrors-prettier - rev: v2.6.2 + rev: v2.7.1 hooks: - id: prettier additional_dependencies: - - prettier@2.6.2 + - prettier@2.7.1 - prettier-plugin-sort-json@0.0.2 exclude_types: - python diff --git a/custom_components/bodymiscale/__init__.py b/custom_components/bodymiscale/__init__.py index b325e95..e786789 100644 --- a/custom_components/bodymiscale/__init__.py +++ b/custom_components/bodymiscale/__init__.py @@ -1,52 +1,44 @@ """Support for bodymiscale.""" +import asyncio import logging -from typing import Any +from functools import partial +from typing import Any, MutableMapping, Optional import homeassistant.helpers.config_validation as cv import voluptuous as vol from awesomeversion import AwesomeVersion +from cachetools import TTLCache from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_SENSORS, STATE_OK, STATE_PROBLEM from homeassistant.const import __version__ as HA_VERSION from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import StateType -from custom_components.bodymiscale.coordinator import BodyScaleCoordinator +from custom_components.bodymiscale.metrics import BodyScaleMetricsHandler +from custom_components.bodymiscale.models import Metric +from custom_components.bodymiscale.util import get_bmi_label, get_ideal_weight -from .body_metrics import BodyMetricsImpedance -from .body_score import BodyScore from .const import ( - ATTR_AGE, - ATTR_BMI, ATTR_BMILABEL, - ATTR_BMR, - ATTR_BODY, - ATTR_BODY_SCORE, - ATTR_BONES, - ATTR_FAT, ATTR_FATMASSTOGAIN, ATTR_FATMASSTOLOSE, ATTR_IDEAL, - ATTR_LBM, - ATTR_METABOLIC, - ATTR_MUSCLE, ATTR_PROBLEM, - ATTR_PROTEIN, - ATTR_VISCERAL, - ATTR_WATER, COMPONENT, CONF_BIRTHDAY, CONF_GENDER, CONF_HEIGHT, CONF_SENSOR_IMPEDANCE, CONF_SENSOR_WEIGHT, - COORDINATORS, DOMAIN, + HANDLERS, MIN_REQUIRED_HA_VERSION, PLATFORMS, PROBLEM_NONE, STARTUP_MESSAGE, + UPDATE_DELAY, ) from .entity import BodyScaleBaseEntity @@ -96,17 +88,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: DOMAIN, { COMPONENT: EntityComponent(_LOGGER, DOMAIN, hass), - COORDINATORS: {}, + HANDLERS: {}, }, ) _LOGGER.info(STARTUP_MESSAGE) - coordinator = hass.data[DOMAIN][COORDINATORS][ - entry.entry_id - ] = BodyScaleCoordinator(hass, {**entry.data, **entry.options}) + handler = hass.data[DOMAIN][HANDLERS][entry.entry_id] = BodyScaleMetricsHandler( + hass, {**entry.data, **entry.options} + ) component: EntityComponent = hass.data[DOMAIN][COMPONENT] - await component.async_add_entities([Bodymiscale(coordinator)]) + await component.async_add_entities([Bodymiscale(handler)]) hass.config_entries.async_setup_platforms(entry, PLATFORMS) # Reload entry when its updated. @@ -123,8 +115,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: component: EntityComponent = hass.data[DOMAIN][COMPONENT] await component.async_prepare_reload() - del hass.data[DOMAIN][COORDINATORS][entry.entry_id] - if len(hass.data[DOMAIN][COORDINATORS]) == 0: + del hass.data[DOMAIN][HANDLERS][entry.entry_id] + if len(hass.data[DOMAIN][HANDLERS]) == 0: hass.data.pop(DOMAIN) return unload_ok @@ -170,72 +162,69 @@ class Bodymiscale(BodyScaleBaseEntity): gender and impedance (if configured). """ - def __init__(self, coordinator: BodyScaleCoordinator): + def __init__(self, handler: BodyScaleMetricsHandler): """Initialize the Bodymiscale component.""" super().__init__( - coordinator, + handler, EntityDescription( - key="Bodymiscale", name=coordinator.config[CONF_NAME], icon="mdi:human" + key="bodymiscale", name=handler.config[CONF_NAME], icon="mdi:human" ), ) + self._timer_handle: Optional[asyncio.TimerHandle] = None + self._available_metrics: MutableMapping[str, StateType] = TTLCache( + maxsize=len(Metric), ttl=60 + ) + + async def async_added_to_hass(self) -> None: + """After being added to hass.""" + await super().async_added_to_hass() + + loop = asyncio.get_event_loop() + + def on_value(value: StateType, *, metric: Metric) -> None: + if metric == Metric.STATUS: + self._attr_state = STATE_OK if value == PROBLEM_NONE else STATE_PROBLEM + self._available_metrics[ATTR_PROBLEM] = value + else: + self._available_metrics[metric.value] = value - def _on_update(self) -> None: - """Perform actions on update.""" - if self._coordinator.problems == PROBLEM_NONE: - self._attr_state = STATE_OK - else: - self._attr_state = STATE_PROBLEM - self.async_write_ha_state() + if self._timer_handle is not None: + self._timer_handle.cancel() + self._timer_handle = loop.call_later( + UPDATE_DELAY, self.async_write_ha_state + ) + + remove_subscriptions = [] + for metric in Metric: + remove_subscriptions.append( + self._handler.subscribe(metric, partial(on_value, metric=metric)) + ) + + def on_remove() -> None: + for subscription in remove_subscriptions: + subscription() + + self.async_on_remove(on_remove) @property def state_attributes(self) -> dict[str, Any]: """Return the attributes of the entity.""" attrib = { - ATTR_PROBLEM: self._coordinator.problems, - CONF_HEIGHT: self._coordinator.config[CONF_HEIGHT], - CONF_GENDER: self._coordinator.config[CONF_GENDER].value, - ATTR_AGE: self._coordinator.config[ATTR_AGE], - CONF_SENSOR_WEIGHT: self._coordinator.weight, + CONF_HEIGHT: self._handler.config[CONF_HEIGHT], + CONF_GENDER: self._handler.config[CONF_GENDER].value, + ATTR_IDEAL: get_ideal_weight(self._handler.config), + **self._available_metrics, } - if CONF_SENSOR_IMPEDANCE in self._coordinator.config: - attrib[CONF_SENSOR_IMPEDANCE] = self._coordinator.impedance - - metrics = self._coordinator.metrics - - if metrics: - attrib[ATTR_BMI] = f"{metrics.bmi:.1f}" - attrib[ATTR_BMR] = f"{metrics.bmr:.0f}" - attrib[ATTR_VISCERAL] = f"{metrics.visceral_fat:.0f}" - attrib[ATTR_IDEAL] = f"{metrics.ideal_weight:.2f}" - attrib[ATTR_BMILABEL] = metrics.bmi_label - - if isinstance(metrics, BodyMetricsImpedance): - bodyscale = [ - "Obese", - "Overweight", - "Thick-set", - "Lack-exercise", - "Balanced", - "Balanced-muscular", - "Skinny", - "Balanced-skinny", - "Skinny-muscular", - ] - attrib[ATTR_LBM] = f"{metrics.lbm_coefficient:.1f}" - attrib[ATTR_FAT] = f"{metrics.fat_percentage:.1f}" - attrib[ATTR_WATER] = f"{metrics.water_percentage:.1f}" - attrib[ATTR_BONES] = f"{metrics.bone_mass:.2f}" - attrib[ATTR_MUSCLE] = f"{metrics.muscle_mass:.2f}" - fat_mass_to_ideal = metrics.fat_mass_to_ideal - if fat_mass_to_ideal["type"] == "to_lose": - attrib[ATTR_FATMASSTOLOSE] = f"{fat_mass_to_ideal['mass']:.2f}" - else: - attrib[ATTR_FATMASSTOGAIN] = f"{fat_mass_to_ideal['mass']:.2f}" - attrib[ATTR_PROTEIN] = f"{metrics.protein_percentage:.1f}" - attrib[ATTR_BODY] = bodyscale[metrics.body_type] - attrib[ATTR_METABOLIC] = f"{metrics.metabolic_age:.0f}" - body_score = BodyScore(metrics) - attrib[ATTR_BODY_SCORE] = f"{body_score.body_score:.0f}" + if Metric.BMI.value in attrib: + attrib[ATTR_BMILABEL] = get_bmi_label(attrib[Metric.BMI.value]) + + if Metric.FAT_MASS_2_IDEAL_WEIGHT.value in attrib: + value = attrib.pop(Metric.FAT_MASS_2_IDEAL_WEIGHT.value) + + if value < 0: + attrib[ATTR_FATMASSTOLOSE] = value * -1 + else: + attrib[ATTR_FATMASSTOGAIN] = value return attrib diff --git a/custom_components/bodymiscale/body_metrics.py b/custom_components/bodymiscale/body_metrics.py deleted file mode 100644 index cc24e0e..0000000 --- a/custom_components/bodymiscale/body_metrics.py +++ /dev/null @@ -1,319 +0,0 @@ -"""Body metrics module.""" -from functools import cached_property -from typing import Union - -from .body_scales import BodyScale -from .const import ( - CONSTRAINT_HEIGHT_MAX, - CONSTRAINT_IMPEDANCE_MAX, - CONSTRAINT_WEIGHT_MAX, - CONSTRAINT_WEIGHT_MIN, -) -from .models import Gender - - -def _check_value_constraints(value: float, minimum: float, maximum: float) -> float: - """Set the value to a boundary if it overflows.""" - if value < minimum: - return minimum - if value > maximum: - return maximum - return value - - -class BodyMetrics: - """Body metrics implementation.""" - - def __init__(self, weight: float, height: int, age: int, gender: Gender): - # Check for potential out of boundaries - if height > CONSTRAINT_HEIGHT_MAX: - raise Exception(f"Height is too high (limit: {CONSTRAINT_HEIGHT_MAX}cm)") - if not (CONSTRAINT_WEIGHT_MIN < weight < CONSTRAINT_WEIGHT_MAX): - raise Exception( - f"Weight not within {CONSTRAINT_WEIGHT_MIN} and {CONSTRAINT_WEIGHT_MAX} kg" - ) - if age > 99: - raise Exception("Age is too high (limit >99 years)") - - self._weight = weight - self._height = height - self._age = age - self._gender = gender - - @property - def weight(self) -> float: - """Get weight.""" - return self._weight - - @property - def height(self) -> int: - """Get height.""" - return self._height - - @property - def age(self) -> int: - """Get age.""" - return self._age - - @property - def gender(self) -> Gender: - """Get gender.""" - return self._gender - - @cached_property - def bmi(self) -> float: - """Get MBI.""" - bmi = self._weight / ((self._height / 100) * (self._height / 100)) - return _check_value_constraints(bmi, 10, 90) - - @cached_property - def bmr(self) -> float: - """Get BMR.""" - if self._gender == Gender.FEMALE: - bmr = 864.6 + self._weight * 10.2036 - bmr -= self._height * 0.39336 - bmr -= self._age * 6.204 - - if bmr > 2996: - bmr = 5000 - else: - bmr = 877.8 + self._weight * 14.916 - bmr -= self._height * 0.726 - bmr -= self._age * 8.976 - - if bmr > 2322: - bmr = 5000 - - return _check_value_constraints(bmr, 500, 5000) - - @cached_property - def visceral_fat(self) -> float: - """Get Visceral Fat.""" - if self._gender == Gender.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 _check_value_constraints(vfal, 1, 50) - - @cached_property - def ideal_weight(self) -> float: - """Get ideal weight (just doing a reverse BMI, should be something better).""" - # Uses mi fit algorithm (or holtek's one) - if self._gender == Gender.FEMALE: - return (self._height - 70) * 0.6 - return (self._height - 80) * 0.7 - - @property - def bmi_label(self) -> str: # pylint: disable=too-many-return-statements - """Get BMI label.""" - bmi = self.bmi - if bmi < 18.5: - return "Underweight" - if bmi < 25: - return "Normal or Healthy Weight" - if bmi < 27: - return "Slight overweight" - if bmi < 30: - return "Overweight" - if bmi < 35: - return "Moderate obesity" - if bmi < 40: - return "Severe obesity" - return "Massive obesity" - - -class BodyMetricsImpedance(BodyMetrics): - """body metrics with impedance implementation.""" - - def __init__( # pylint: disable=too-many-arguments - self, weight: float, height: int, age: int, gender: Gender, impedance: int - ): - super().__init__(weight, height, age, gender) - if impedance > CONSTRAINT_IMPEDANCE_MAX: - raise Exception( - f"Impedance is too high (limit >{CONSTRAINT_IMPEDANCE_MAX}ohm)" - ) - self._impedance = impedance - self._scale = BodyScale(age, height, gender, weight) - - @property - def scale(self) -> BodyScale: - """Get body scale.""" - return self._scale - - @cached_property - def lbm_coefficient(self) -> float: - """Get LBM coefficient (with impedance).""" - 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 - - @cached_property - def fat_percentage(self) -> float: - """Get fat percentage.""" - # Set a constant to remove from LBM - if self._gender == "female": - const = 9.25 if self._age <= 49 else 7.25 - else: - const = 0.8 - - # Calculate body fat percentage - coefficient = 1.0 - if self._gender == "female": - if self._weight > 60: - coefficient = 0.96 - elif self._weight < 50: - coefficient = 1.02 - - if self._height > 160 and (self._weight < 50 or self._weight > 60): - coefficient *= 1.03 - elif self._weight < 61: # gender = male - coefficient = 0.98 - - fat_percentage = ( - 1.0 - (((self.lbm_coefficient - const) * coefficient) / self._weight) - ) * 100 - - # Capping body fat percentage - if fat_percentage > 63: - fat_percentage = 75 - return _check_value_constraints(fat_percentage, 5, 75) - - @cached_property - def water_percentage(self) -> float: - """Get water percentage.""" - water_percentage = (100 - self.fat_percentage) * 0.7 - - coefficient = 1.02 if water_percentage <= 50 else 0.98 - - # Capping water percentage - if water_percentage * coefficient >= 65: - water_percentage = 75 - return _check_value_constraints(water_percentage * coefficient, 35, 75) - - @cached_property - def bone_mass(self) -> float: - """Get bone mass.""" - if self._gender == Gender.FEMALE: - base = 0.245691014 - else: - base = 0.18016894 - - bone_mass = (base - (self.lbm_coefficient * 0.05158)) * -1 - - if bone_mass > 2.2: - bone_mass += 0.1 - else: - bone_mass -= 0.1 - - # Capping bone mass - if self._gender == Gender.FEMALE and bone_mass > 5.1: - bone_mass = 8 - elif self._gender == Gender.MALE and bone_mass > 5.2: - bone_mass = 8 - - return _check_value_constraints(bone_mass, 0.5, 8) - - @cached_property - def muscle_mass(self) -> float: - """Get muscle mass.""" - muscle_mass = ( - self._weight - - ((self.fat_percentage * 0.01) * self._weight) - - self.bone_mass - ) - - # Capping muscle mass - if self._gender == Gender.FEMALE and muscle_mass >= 84: - muscle_mass = 120 - elif self._gender == Gender.MALE and muscle_mass >= 93.5: - muscle_mass = 120 - - return _check_value_constraints(muscle_mass, 10, 120) - - @cached_property - def metabolic_age(self) -> float: - """Get metabolic age.""" - if self._gender == Gender.FEMALE: - metabolic_age = ( - (self._height * -1.1165) - + (self._weight * 1.5784) - + (self._age * 0.4615) - + (self._impedance * 0.0415) - + 83.2548 - ) - else: - metabolic_age = ( - (self._height * -0.7471) - + (self._weight * 0.9161) - + (self._age * 0.4184) - + (self._impedance * 0.0517) - + 54.2267 - ) - return _check_value_constraints(metabolic_age, 15, 80) - - @cached_property - def fat_mass_to_ideal(self) -> dict[str, Union[str, float]]: - """Get missig mass to idea weight.""" - mass = (self._weight * (self.fat_percentage / 100)) - ( - self._weight * (self._scale.fat_percentage[2] / 100) - ) - if mass < 0: - return {"type": "to_gain", "mass": mass * -1} - - return {"type": "to_lose", "mass": mass} - - @cached_property - def protein_percentage(self) -> float: - """Get protetin percentage (warn: guessed formula).""" - # Use original algorithm from mi fit (or legacy guess one) - protein_percentage = (self.muscle_mass / self._weight) * 100 - protein_percentage -= self.water_percentage - - return _check_value_constraints(protein_percentage, 5, 32) - - @cached_property - def body_type(self) -> int: - """Get body type (out of nine possible).""" - if self.fat_percentage > self._scale.fat_percentage[2]: - factor = 0 - elif self.fat_percentage < self._scale.fat_percentage[1]: - factor = 2 - else: - factor = 1 - - if self.muscle_mass > self._scale.muscle_mass[1]: - return 2 + (factor * 3) - if self.muscle_mass < self._scale.muscle_mass[0]: - return factor * 3 - - return 1 + (factor * 3) diff --git a/custom_components/bodymiscale/body_scales.py b/custom_components/bodymiscale/body_scales.py deleted file mode 100644 index c8d741f..0000000 --- a/custom_components/bodymiscale/body_scales.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Body scale module.""" -from functools import cached_property - -from custom_components.bodymiscale.models import Gender - - -class BodyScale: - """body scale implementation.""" - - def __init__(self, age: int, height: int, gender: Gender, weight: float): - self._age = age - self._height = height - self._gender = gender - self._weight = weight - - @property - def bmi(self) -> list[float]: - """Get BMI.""" - # 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] - - @cached_property - def fat_percentage(self) -> list[float]: - """Get fat percentage.""" - - # The included tables where quite strange, maybe bogus, replaced them with better ones... - scales: list[dict] = [ - { - "min": 0, - "max": 12, - Gender.FEMALE: [12.0, 21.0, 30.0, 34.0], - Gender.MALE: [7.0, 16.0, 25.0, 30.0], - }, - { - "min": 12, - "max": 14, - Gender.FEMALE: [15.0, 24.0, 33.0, 37.0], - Gender.MALE: [7.0, 16.0, 25.0, 30.0], - }, - { - "min": 14, - "max": 16, - Gender.FEMALE: [18.0, 27.0, 36.0, 40.0], - Gender.MALE: [7.0, 16.0, 25.0, 30.0], - }, - { - "min": 16, - "max": 18, - Gender.FEMALE: [20.0, 28.0, 37.0, 41.0], - Gender.MALE: [7.0, 16.0, 25.0, 30.0], - }, - { - "min": 18, - "max": 40, - Gender.FEMALE: [21.0, 28.0, 35.0, 40.0], - Gender.MALE: [11.0, 17.0, 22.0, 27.0], - }, - { - "min": 40, - "max": 60, - Gender.FEMALE: [22.0, 29.0, 36.0, 41.0], - Gender.MALE: [12.0, 18.0, 23.0, 28.0], - }, - { - "min": 60, - "max": 100, - Gender.FEMALE: [23.0, 30.0, 37.0, 42.0], - Gender.MALE: [14.0, 20.0, 25.0, 30.0], - }, - ] - - for scale in scales: - if scale["min"] <= self._age < scale["max"]: - return scale[self._gender] # type: ignore - - # will never happen but mypy required it - raise NotImplementedError - - @cached_property - def muscle_mass(self) -> list[float]: - """Get muscle mass.""" - scales: list[dict] = [ - { - "min": {Gender.MALE: 170, Gender.FEMALE: 160}, - Gender.FEMALE: [36.5, 42.6], - Gender.MALE: [49.4, 59.5], - }, - { - "min": {Gender.MALE: 160, Gender.FEMALE: 150}, - Gender.FEMALE: [32.9, 37.6], - Gender.MALE: [44.0, 52.5], - }, - { - "min": {Gender.MALE: 0, Gender.FEMALE: 0}, - Gender.FEMALE: [29.1, 34.8], - Gender.MALE: [38.5, 46.6], - }, - ] - - for scale in scales: - if self._height >= scale["min"][self._gender]: - return scale[self._gender] # type: ignore - - # will never happen but mypy required it - raise NotImplementedError - - @property - def water_percentage(self) -> list[float]: - """Get water percentage.""" - if self._gender == Gender.MALE: - return [55.0, 65.1] - - return [45.0, 60.1] - - @property - def visceral_fat(self) -> list[float]: - """Get visceral fat.""" - return [10.0, 15.0] - - @cached_property - def bone_mass(self) -> list[float]: - """Get bone mass.""" - scales = [ - { - Gender.MALE: {"min": 75.0, "scale": [2.0, 4.2]}, - Gender.FEMALE: {"min": 60.0, "scale": [1.8, 3.9]}, - }, - { - Gender.MALE: {"min": 60.0, "scale": [1.9, 4.1]}, - Gender.FEMALE: {"min": 45.0, "scale": [1.5, 3.8]}, - }, - { - Gender.MALE: {"min": 0.0, "scale": [1.6, 3.9]}, - Gender.FEMALE: {"min": 0.0, "scale": [1.3, 3.6]}, - }, - ] - - for scale in scales: - if self._weight >= scale[self._gender]["min"]: # type: ignore - return scale[self._gender]["scale"] # type: ignore - - # will never happen but mypy required it - raise NotImplementedError - - @cached_property - def bmr(self) -> float: - """Get BMR.""" - coefficients = { - Gender.MALE: {30: 21.6, 50: 20.07, 100: 19.35}, - Gender.FEMALE: {30: 21.24, 50: 19.53, 100: 18.63}, - } - - for age, coefficient in coefficients[self._gender].items(): - if self._age < age: - return self._weight * coefficient - - # will never happen but mypy required it - raise NotImplementedError - - @property - def protein_percentage(self) -> list[float]: - """Get protein percentage.""" - return [16, 20] - - @cached_property - def ideal_weight(self) -> list[float]: - """Get ideal weight scale (BMI scale converted to weights).""" - scales = [] - for scale in self.bmi: - scales.append((scale * self._height) * self._height / 10000) - return scales - - @property - def body_score(self) -> list[float]: - """Get body score.""" - # very bad, bad, normal, good, better - return [50.0, 60.0, 80.0, 90.0] - - @property - def body_type(self) -> list[str]: - """Get body type.""" - return [ - "obese", - "overweight", - "thick-set", - "lack-exercise", - "balanced", - "balanced-muscular", - "skinny", - "balanced-skinny", - "skinny-muscular", - ] diff --git a/custom_components/bodymiscale/body_score.py b/custom_components/bodymiscale/body_score.py deleted file mode 100644 index 6b0c317..0000000 --- a/custom_components/bodymiscale/body_score.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Body score module.""" -from functools import cached_property -from typing import Union - -from . import BodyMetricsImpedance -from .models import Gender - - -def _get_malus( - data: float, - min_data: float, - max_data: float, - max_malus: Union[int, float], - min_malus: int, -) -> float: - result = ((data - max_data) / (min_data - max_data)) * float(max_malus - min_malus) - if result >= 0.0: - return result - return 0.0 - - -class BodyScore: - """Body score implementation.""" - - def __init__(self, metrics: BodyMetricsImpedance): - self._metrics = metrics - - @cached_property - def body_score(self) -> float: - """Get/calculate body score.""" - score = 100.0 - score -= self._calculate_bmi_deduct_score() - score -= self._calculate_body_fat_deduct_score() - score -= self._calculate_muscle_deduct_score() - score -= self._calculate_water_deduct_score() - score -= self._calculate_body_visceral_deduct_score() - score -= self._calculate_bone_deduct_score() - score -= self._calculate_basal_metabolism_deduct_score() - if self._metrics.protein_percentage: - score -= self._calculate_protein_deduct_score() - - return score - - def _calculate_bmi_deduct_score( # pylint: disable=too-many-return-statements - self, - ) -> float: - if not self._metrics.height >= 90: - # "BMI is not reasonable - return 0.0 - - bmi_low = 15.0 - bmi_very_low = 14.0 - bmi_normal = 18.5 - bmi_overweight = 28.0 - bmi_obese = 32.0 - fat_scale = self._metrics.scale.fat_percentage - - # Perfect range (bmi >= 18.5 and fat_percentage not high for adults, bmi >= 15.0 for kids - if self._metrics.fat_percentage < fat_scale[2] and ( - (self._metrics.bmi >= 18.5 and self._metrics.age >= 18) - or self._metrics.bmi >= bmi_very_low - and self._metrics.age < 18 - ): - return 0.0 - - # Extremely skinny (bmi < 14) - if self._metrics.bmi <= bmi_very_low: - return 30.0 - # Too skinny (bmi between 14 and 15) - if self._metrics.bmi < bmi_low: - return _get_malus(self._metrics.bmi, bmi_very_low, bmi_low, 30, 15) + 15.0 - # Skinny (for adults, between 15 and 18.5) - if self._metrics.bmi < bmi_normal and self._metrics.age >= 18: - return _get_malus(self._metrics.bmi, 15.0, 18.5, 15, 5) + 5.0 - - # Normal or high bmi but too much bodyfat - if ( - self._metrics.fat_percentage >= fat_scale[2] - and (self._metrics.bmi >= bmi_low and self._metrics.age < 18) - or (self._metrics.bmi >= bmi_normal and self._metrics.age >= 18) - ): - # Obese - if self._metrics.bmi >= bmi_obese: - return 10.0 - # Overweight - if self._metrics.bmi > bmi_overweight: - return _get_malus(self._metrics.bmi, 28.0, 25.0, 5, 10) + 5.0 - - return 0.0 - - def _calculate_body_fat_deduct_score(self) -> float: - scale = self._metrics.scale.fat_percentage - - if self._metrics.gender == Gender.MALE: - best = scale[2] - 3.0 - else: - best = scale[2] - 2.0 - - # Slightly low in fat or low part or normal fat - if scale[0] <= self._metrics.fat_percentage < best: - return 0.0 - if self._metrics.fat_percentage >= scale[3]: - return 20.0 - - # Slightly high body fat - if self._metrics.fat_percentage < scale[3]: - return ( - _get_malus(self._metrics.fat_percentage, scale[3], scale[2], 20, 10) - + 10.0 - ) - - # High part of normal fat - if self._metrics.fat_percentage <= scale[2]: - return _get_malus(self._metrics.fat_percentage, scale[2], best, 3, 9) + 3.0 - - # Very low in fat - if self._metrics.fat_percentage < scale[0]: - return _get_malus(self._metrics.fat_percentage, 1.0, scale[0], 3, 10) + 3.0 - - return 0.0 - - def _calculate_muscle_deduct_score(self) -> float: - scale = self._metrics.scale.muscle_mass - - # 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._metrics.muscle_mass >= scale[0]: - return 0.0 - if self._metrics.muscle_mass < (scale[0] - 5.0): - return 10.0 - return ( - _get_malus(self._metrics.muscle_mass, scale[0] - 5.0, scale[0], 10, 5) + 5.0 - ) - - def _calculate_water_deduct_score(self) -> float: - # 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 - scale = self._metrics.scale.water_percentage - - if self._metrics.water_percentage >= scale[0]: - return 0.0 - if self._metrics.water_percentage <= (scale[0] - 5.0): - return 10.0 - return ( - _get_malus(self._metrics.water_percentage, scale[0] - 5.0, scale[0], 10, 5) - + 5.0 - ) - - def _calculate_body_visceral_deduct_score(self) -> float: - # No malus = normal; maximum malus (15.0) = very high; malus = between 10 and 15 - # with your visceral fat in your high range - scale = self._metrics.scale.visceral_fat - - if self._metrics.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 - if self._metrics.visceral_fat >= scale[1]: - return 15.0 - return _get_malus(self._metrics.visceral_fat, scale[1], scale[0], 15, 10) + 10.0 - - def _calculate_bone_deduct_score(self) -> float: - scale = self._metrics.scale.bone_mass - - if self._metrics.bone_mass >= scale[0]: - return 0.0 - if self._metrics.bone_mass <= (scale[0] - 0.3): - return 10.0 - return ( - _get_malus(self._metrics.bone_mass, scale[0] - 0.3, scale[0], 10, 5) + 5.0 - ) - - def _calculate_basal_metabolism_deduct_score(self) -> float: - # Get normal BMR - normal = self._metrics.scale.bmr - - if self._metrics.bmr >= normal: - return 0.0 - if self._metrics.bmr <= (normal - 300): - return 6.0 - # It's really + 5.0 in the app, but it's probably a mistake, should be 3.0 - return _get_malus(self._metrics.bmr, normal - 300, normal, 6, 3) + 5.0 - - def _calculate_protein_deduct_score(self) -> float: - # low: 10,16; normal: 16,17 - # Check limits - if self._metrics.protein_percentage > 17.0: - return 0.0 - if self._metrics.protein_percentage < 10.0: - return 10.0 - - # Return values for low proteins or normal proteins - if self._metrics.protein_percentage <= 16.0: - return _get_malus(self._metrics.protein_percentage, 10.0, 16.0, 10, 5) + 5.0 - if self._metrics.protein_percentage <= 17.0: - return _get_malus(self._metrics.protein_percentage, 16.0, 17.0, 5, 3) + 3.0 - - return 0.0 diff --git a/custom_components/bodymiscale/config_flow.py b/custom_components/bodymiscale/config_flow.py index c8e5d42..19c8189 100644 --- a/custom_components/bodymiscale/config_flow.py +++ b/custom_components/bodymiscale/config_flow.py @@ -102,6 +102,7 @@ async def async_step_user( return self.async_show_form( step_id="user", + errors=errors, data_schema=vol.Schema( { vol.Required( @@ -122,7 +123,11 @@ async def async_step_options( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle step options.""" + errors = {} if user_input is not None: + if user_input[CONF_HEIGHT] > CONSTRAINT_HEIGHT_MAX: + errors[CONF_HEIGHT] = "height_limit" + return self.async_create_entry( title=self._data[CONF_NAME], data=self._data, options=user_input ) @@ -131,6 +136,7 @@ async def async_step_options( return self.async_show_form( step_id="options", data_schema=_get_options_schema(user_input), + errors=errors, ) diff --git a/custom_components/bodymiscale/const.py b/custom_components/bodymiscale/const.py index 7f002ec..4b60cb1 100644 --- a/custom_components/bodymiscale/const.py +++ b/custom_components/bodymiscale/const.py @@ -14,6 +14,7 @@ CONF_HEIGHT = "height" CONF_SENSOR_IMPEDANCE = "impedance" CONF_SENSOR_WEIGHT = "weight" +CONF_SCALE = "scale" ATTR_AGE = "age" ATTR_BMI = "bmi" @@ -58,6 +59,7 @@ MIN = "min" MAX = "max" COMPONENT = "component" -COORDINATORS = "coordinators" +HANDLERS = "handlers" -PLATFORMS: set[Platform] = set() +PLATFORMS: set[Platform] = {Platform.SENSOR} +UPDATE_DELAY = 2.0 diff --git a/custom_components/bodymiscale/coordinator.py b/custom_components/bodymiscale/coordinator.py deleted file mode 100644 index 5d35058..0000000 --- a/custom_components/bodymiscale/coordinator.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Coordinator module.""" -import logging -from datetime import datetime -from typing import Any, Optional, Union - -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, - STATE_UNAVAILABLE, - STATE_UNKNOWN, -) -from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, State, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.event import async_track_state_change_event - -from .body_metrics import BodyMetrics, BodyMetricsImpedance -from .const import ( - ATTR_AGE, - CONF_BIRTHDAY, - CONF_GENDER, - CONF_HEIGHT, - CONF_SENSOR_IMPEDANCE, - CONF_SENSOR_WEIGHT, - CONSTRAINT_IMPEDANCE_MAX, - CONSTRAINT_IMPEDANCE_MIN, - CONSTRAINT_WEIGHT_MAX, - CONSTRAINT_WEIGHT_MIN, - MAX, - MIN, - PROBLEM_NONE, - UNIT_POUNDS, -) -from .models import Gender - -_LOGGER = logging.getLogger(__name__) - - -def _get_age(date: str) -> int: - born = datetime.strptime(date, "%Y-%m-%d") - today = datetime.today() - age = today.year - born.year - if (today.month, today.day) < (born.month, born.day): - age -= 1 - return age - - -class BodyScaleCoordinator: - """Body scale coordinator.""" - - READINGS = { - CONF_SENSOR_WEIGHT: { - MIN: CONSTRAINT_WEIGHT_MIN, - MAX: CONSTRAINT_WEIGHT_MAX, - }, - CONF_SENSOR_IMPEDANCE: { - MIN: CONSTRAINT_IMPEDANCE_MIN, - MAX: CONSTRAINT_IMPEDANCE_MAX, - }, - } - - def __init__(self, hass: HomeAssistant, config: dict[str, Any]): - self._hass = hass - self._config: dict[str, Any] = { - **config, - ATTR_AGE: _get_age(config[CONF_BIRTHDAY]), - CONF_GENDER: Gender(config[CONF_GENDER]), - } - self._subscriptions: list[CALLBACK_TYPE] = [] - self._remove_listener: Optional[CALLBACK_TYPE] = None - - self._problems = PROBLEM_NONE - self._weight: Optional[float] = None - self._impedance: Optional[int] = None - - def subscribe(self, callback_func: CALLBACK_TYPE) -> CALLBACK_TYPE: - """Subscribe for changes.""" - self._subscriptions.append(callback_func) - - if len(self._subscriptions) == 1: - sensors = [self._config[CONF_SENSOR_WEIGHT]] - if CONF_SENSOR_IMPEDANCE in self._config: - sensors.append(self._config[CONF_SENSOR_IMPEDANCE]) - - self._remove_listener = async_track_state_change_event( - self._hass, - sensors, - self._state_changed_event, - ) - - for entity_id in sensors: - if (state := self._hass.states.get(entity_id)) is not None: - self._state_changed(entity_id, state) - - @callback # type: ignore[misc] - def remove_listener() -> None: - """Remove subscribtion.""" - self._subscriptions.remove(callback_func) - - if len(self._subscriptions) == 0 and self._remove_listener: - self._remove_listener() - - return remove_listener - - @callback # type: ignore[misc] - def _state_changed_event(self, event: Event) -> None: - """Sensor state change event.""" - self._state_changed(event.data.get("entity_id"), event.data.get("new_state")) - - @callback # type: ignore[misc] - def _state_changed(self, entity_id: str, new_state: State) -> None: - """Update the sensor status.""" - if new_state is None: - return - - value = new_state.state - _LOGGER.debug("Received callback from %s with value %s", entity_id, value) - if value == STATE_UNKNOWN: - return - - if value != STATE_UNAVAILABLE: - value = float(value) - - if entity_id == self._config[CONF_SENSOR_WEIGHT]: - if new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_POUNDS: - value = value * 0.45359237 - self._weight = value - elif entity_id == self._config.get(CONF_SENSOR_IMPEDANCE, None): - self._impedance = value - else: - raise HomeAssistantError( - f"Unknown reading from sensor {entity_id}: {value}" - ) - - self._update_state() - - def _update_state(self) -> None: - """Update the state of the class based sensor data.""" - problems = [] - sensor_types = [CONF_SENSOR_WEIGHT] - if CONF_SENSOR_IMPEDANCE in self._config: - sensor_types.append(CONF_SENSOR_IMPEDANCE) - - for sensor_type in sensor_types: - params = self.READINGS[sensor_type] - if (value := getattr(self, f"_{sensor_type}")) is not None: - if value == STATE_UNAVAILABLE: - problems.append(f"{sensor_type} unavailable") - else: - if value < params[MIN]: - problems.append(f"{sensor_type} low") - elif value > params[MAX]: - problems.append(f"{sensor_type} high") - - if problems: - self._problems = ", ".join(problems) - else: - self._problems = PROBLEM_NONE - - _LOGGER.debug("New data processed") - for sub_callback in self._subscriptions: - sub_callback() - - @property - def config(self) -> dict[str, Any]: - """Return config.""" - return self._config - - @property - def problems(self) -> str: - """Return problems.""" - return self._problems - - @property - def weight(self) -> Optional[float]: - """Return weight.""" - return self._weight - - @property - def impedance(self) -> Optional[float]: - """Return impedance.""" - return self._impedance - - @property - def metrics(self) -> Union[BodyMetrics, BodyMetricsImpedance, None]: - """Return metrics object.""" - if CONF_SENSOR_WEIGHT in self._problems or self._weight is None: - return None - - height: int = self._config[CONF_HEIGHT] - gender: Gender = self._config[CONF_GENDER] - age = self._config[ATTR_AGE] - - metrics = BodyMetrics(self._weight, height, age, gender) - - if CONF_SENSOR_IMPEDANCE not in self._problems and self._impedance is not None: - metrics = BodyMetricsImpedance( - self._weight, height, age, gender, self._impedance - ) - - return metrics diff --git a/custom_components/bodymiscale/entity.py b/custom_components/bodymiscale/entity.py index 4a3d616..75149e1 100644 --- a/custom_components/bodymiscale/entity.py +++ b/custom_components/bodymiscale/entity.py @@ -1,5 +1,4 @@ """Bodymiscale entity module.""" -from abc import abstractmethod from typing import Optional from homeassistant.const import CONF_NAME @@ -7,7 +6,7 @@ from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from .const import DOMAIN, NAME, VERSION -from .coordinator import BodyScaleCoordinator +from .metrics import BodyScaleMetricsHandler class BodyScaleBaseEntity(Entity): # type: ignore[misc] @@ -17,12 +16,12 @@ class BodyScaleBaseEntity(Entity): # type: ignore[misc] def __init__( self, - coordinator: BodyScaleCoordinator, + handler: BodyScaleMetricsHandler, entity_description: Optional[EntityDescription] = None, ): """Initialize the entity.""" super().__init__() - self._coordinator = coordinator + self._handler = handler if entity_description: self.entity_description = entity_description elif not hasattr(self, "entity_description"): @@ -33,7 +32,7 @@ def __init__( if not self.entity_description.key: raise ValueError('"entity_description.key" must be either set!') - name = coordinator.config[CONF_NAME] + name = handler.config[CONF_NAME] self._attr_unique_id = "_".join([DOMAIN, name, self.entity_description.key]) if self.entity_description.name: @@ -52,14 +51,3 @@ def device_info(self) -> Optional[DeviceInfo]: name=NAME, sw_version=VERSION, ) - - @abstractmethod - def _on_update(self) -> None: - """Perform actions on update.""" - raise NotImplementedError - - async def async_added_to_hass(self) -> None: - """After being added to hass.""" - await super().async_added_to_hass() - - self.async_on_remove(self._coordinator.subscribe(self._on_update)) diff --git a/custom_components/bodymiscale/manifest.json b/custom_components/bodymiscale/manifest.json index 66c9609..24ac71e 100644 --- a/custom_components/bodymiscale/manifest.json +++ b/custom_components/bodymiscale/manifest.json @@ -1,12 +1,11 @@ { "codeowners": ["@dckiller51", "@edenhaus"], "config_flow": true, - "dependencies": [], "documentation": "https://github.com/dckiller51/bodymiscale", "domain": "bodymiscale", "iot_class": "calculated", "issue_tracker": "https://github.com/dckiller51/bodymiscale/issues", "name": "BodyMiScale", - "requirements": [], + "requirements": ["cachetools==5.1.0"], "version": "2.1.1" } diff --git a/custom_components/bodymiscale/metrics/__init__.py b/custom_components/bodymiscale/metrics/__init__.py new file mode 100644 index 0000000..3ebe7c6 --- /dev/null +++ b/custom_components/bodymiscale/metrics/__init__.py @@ -0,0 +1,304 @@ +"""Metrics module.""" + + +import logging +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Callable, Mapping, MutableMapping, Optional + +from cachetools import TTLCache +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, State, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.typing import StateType + +from custom_components.bodymiscale.metrics.scale import Scale + +from ..const import ( + CONF_BIRTHDAY, + CONF_GENDER, + CONF_HEIGHT, + CONF_SCALE, + CONF_SENSOR_IMPEDANCE, + CONF_SENSOR_WEIGHT, + CONSTRAINT_IMPEDANCE_MAX, + CONSTRAINT_IMPEDANCE_MIN, + CONSTRAINT_WEIGHT_MAX, + CONSTRAINT_WEIGHT_MIN, + PROBLEM_NONE, + UNIT_POUNDS, +) +from ..models import Gender, Metric +from .body_score import get_body_score +from .impedance import ( + get_body_type, + get_bone_mass, + get_fat_mass_to_ideal_weight, + get_fat_percentage, + get_lbm, + get_metabolic_age, + get_muscle_mass, + get_protein_percentage, + get_water_percentage, +) +from .weight import get_bmi, get_bmr, get_visceral_fat + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class MetricInfo: + """Metric info.""" + + depends_on: list[Metric] + calculate: Callable[[Mapping[str, Any], Mapping[Metric, StateType]], StateType] + decimals: Optional[int] = None # Round decimals before passing to the subscribers + depended_by: list[Metric] = field(default_factory=list, init=False) + subscribers: list[Callable[[StateType], None]] = field( + default_factory=list, init=False + ) + + +_METRIC_DEPS: dict[Metric, MetricInfo] = { + Metric.STATUS: MetricInfo([], lambda c, s: None), + Metric.AGE: MetricInfo([], lambda c, s: None, 0), + Metric.WEIGHT: MetricInfo([], lambda c, s: None, 2), + Metric.IMPEDANCE: MetricInfo([], lambda c, s: None, 0), + # require weight + Metric.BMI: MetricInfo([Metric.WEIGHT], get_bmi, 1), + Metric.BMR: MetricInfo([Metric.AGE, Metric.WEIGHT], get_bmr, 0), + Metric.VISCERAL_FAT: MetricInfo([Metric.AGE, Metric.WEIGHT], get_visceral_fat, 0), + # require weight & impedance + Metric.LBM: MetricInfo([Metric.AGE, Metric.WEIGHT, Metric.IMPEDANCE], get_lbm, 1), + Metric.FAT_PERCENTAGE: MetricInfo( + [Metric.AGE, Metric.WEIGHT, Metric.LBM], get_fat_percentage, 1 + ), + Metric.WATER_PERCENTAGE: MetricInfo( + [Metric.FAT_PERCENTAGE], get_water_percentage, 1 + ), + Metric.BONE_MASS: MetricInfo([Metric.LBM], get_bone_mass, 2), + Metric.MUSCLE_MASS: MetricInfo( + [Metric.WEIGHT, Metric.FAT_PERCENTAGE, Metric.BONE_MASS], get_muscle_mass, 2 + ), + Metric.METABOLIC_AGE: MetricInfo( + [Metric.WEIGHT, Metric.AGE, Metric.IMPEDANCE], get_metabolic_age, 0 + ), + Metric.PROTEIN_PERCENTAGE: MetricInfo( + [Metric.WEIGHT, Metric.MUSCLE_MASS, Metric.WATER_PERCENTAGE], + get_protein_percentage, + 1, + ), + Metric.FAT_MASS_2_IDEAL_WEIGHT: MetricInfo( + [Metric.WEIGHT, Metric.FAT_PERCENTAGE, Metric.AGE], + get_fat_mass_to_ideal_weight, + 2, + ), + Metric.BODY_TYPE: MetricInfo( + [Metric.MUSCLE_MASS, Metric.FAT_PERCENTAGE, Metric.AGE], + get_body_type, + ), + Metric.BODY_SCORE: MetricInfo( + [ + Metric.BMI, + Metric.FAT_PERCENTAGE, + Metric.AGE, + Metric.MUSCLE_MASS, + Metric.WATER_PERCENTAGE, + Metric.WEIGHT, + Metric.BONE_MASS, + Metric.BMR, + Metric.VISCERAL_FAT, + Metric.PROTEIN_PERCENTAGE, + ], + get_body_score, + 0, + ), +} + + +def _get_age(date: str) -> int: + born = datetime.strptime(date, "%Y-%m-%d") + today = datetime.today() + age = today.year - born.year + if (today.month, today.day) < (born.month, born.day): + age -= 1 + return age + + +def _modify_state_for_subscriber( + metric_info: MetricInfo, state: StateType +) -> StateType: + if isinstance(state, float) and metric_info.decimals is not None: + state = round(state, metric_info.decimals) + + return state + + +class BodyScaleMetricsHandler: + """Body scale metrics handler.""" + + def __init__(self, hass: HomeAssistant, config: dict[str, Any]): + self._available_metrics: MutableMapping[Metric, StateType] = TTLCache( + maxsize=len(Metric), ttl=60 + ) + self._hass = hass + self._config: dict[str, Any] = { + **config, + CONF_GENDER: Gender(config[CONF_GENDER]), + } + + self._config[CONF_SCALE] = Scale( + self._config[CONF_HEIGHT], self._config[CONF_GENDER] + ) + + self._dependencies: dict[Metric, MetricInfo] = {} + for key, value in _METRIC_DEPS.items(): + self._dependencies[key] = value + + for dep in value.depends_on: + info_dep = self._dependencies[dep] + info_dep.depended_by.append(key) + + sensors = [self._config[CONF_SENSOR_WEIGHT]] + if CONF_SENSOR_IMPEDANCE in self._config: + sensors.append(self._config[CONF_SENSOR_IMPEDANCE]) + + self._remove_listener = async_track_state_change_event( + self._hass, + sensors, + self._state_changed_event, + ) + + for entity_id in sensors: + if (state := self._hass.states.get(entity_id)) is not None: + self._state_changed(entity_id, state) + + @property + def config(self) -> Mapping[str, Any]: + """Return config.""" + return self._config + + def subscribe( + self, metric: Metric, callback_func: Callable[[StateType], None] + ) -> CALLBACK_TYPE: + """Subscribe for changes.""" + self._dependencies[metric].subscribers.append(callback_func) + + @callback # type: ignore[misc] + def remove_listener() -> None: + """Remove subscribtion.""" + self._dependencies[metric].subscribers.remove(callback_func) + + # If a state is available call subscriber function with current state. + state = self._available_metrics.get(metric, None) + if state is not None: + callback_func( + _modify_state_for_subscriber(self._dependencies[metric], state) + ) + + return remove_listener + + @callback # type: ignore[misc] + def _state_changed_event(self, event: Event) -> None: + """Sensor state change event.""" + self._state_changed(event.data.get("entity_id"), event.data.get("new_state")) + + @callback # type: ignore[misc] + def _state_changed(self, entity_id: str, new_state: State) -> None: + """Update the sensor status.""" + if new_state is None: + return + + value = new_state.state + _LOGGER.debug("Received callback from %s with value %s", entity_id, value) + if value == STATE_UNKNOWN: + return + + if value != STATE_UNAVAILABLE: + value = float(value) + + if entity_id == self._config[CONF_SENSOR_WEIGHT]: + if self._is_valid( + CONF_SENSOR_WEIGHT, value, CONSTRAINT_WEIGHT_MIN, CONSTRAINT_WEIGHT_MAX + ): + if new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_POUNDS: + value = value * 0.45359237 + + self._update_available_metric(Metric.WEIGHT, value) + elif entity_id == self._config.get(CONF_SENSOR_IMPEDANCE, None): + if self._is_valid( + CONF_SENSOR_IMPEDANCE, + value, + CONSTRAINT_IMPEDANCE_MIN, + CONSTRAINT_IMPEDANCE_MAX, + ): + self._update_available_metric(Metric.IMPEDANCE, value) + else: + raise HomeAssistantError( + f"Unknown reading from sensor {entity_id}: {value}" + ) + + def _is_valid( + self, + name_sensor: str, + state: StateType, + constraint_min: int, + constraint_max: int, + ) -> bool: + problem = None + if state == STATE_UNAVAILABLE: + problem = f"{name_sensor} unavailable" + elif state < constraint_min: + problem = f"{name_sensor} low" + elif state > constraint_max: + problem = f"{name_sensor} high" + + new_statues = [] + for status in self._available_metrics.get(Metric.STATUS, "").split(","): + status = status.strip() + if status == PROBLEM_NONE: + continue + + if status.startswith(name_sensor): + continue + + if status: + new_statues.append(status) + + if problem: + new_statues.append(problem) + + if new_statues: + self._update_available_metric(Metric.STATUS, ", ".join(new_statues)) + return problem is None + + self._update_available_metric(Metric.STATUS, PROBLEM_NONE) + return True + + def _update_available_metric(self, metric: Metric, state: StateType) -> None: + old_state = self._available_metrics.get(metric, None) + if old_state is not None and old_state == state: + _LOGGER.debug("No update required for %s.", metric) + return + + self._available_metrics.setdefault( + Metric.AGE, _get_age(self._config[CONF_BIRTHDAY]) + ) + + self._available_metrics[metric] = state + + metric_info = self._dependencies[metric] + for subscriber in metric_info.subscribers: + subscriber(_modify_state_for_subscriber(metric_info, state)) + + for depended in metric_info.depended_by: + depended_info = self._dependencies[depended] + if all(dep in self._available_metrics for dep in depended_info.depends_on): + value = depended_info.calculate(self._config, self._available_metrics) + if value is not None: + self._update_available_metric(depended, value) diff --git a/custom_components/bodymiscale/metrics/body_score.py b/custom_components/bodymiscale/metrics/body_score.py new file mode 100644 index 0000000..84f513e --- /dev/null +++ b/custom_components/bodymiscale/metrics/body_score.py @@ -0,0 +1,235 @@ +"""Body score module.""" +from collections import namedtuple +from typing import Any, Mapping, Union + +from homeassistant.helpers.typing import StateType + +from ..const import CONF_GENDER, CONF_HEIGHT, CONF_SCALE +from ..models import Gender, Metric + + +def _get_malus( + data: float, + min_data: float, + max_data: float, + max_malus: Union[int, float], + min_malus: Union[int, float], +) -> float: + result = ((data - max_data) / (min_data - max_data)) * float(max_malus - min_malus) + if result >= 0.0: + return result + return 0.0 + + +def _calculate_bmi_deduct_score( # pylint: disable=too-many-return-statements + config: Mapping[str, Any], metrics: Mapping[Metric, StateType] +) -> float: + bmi_low = 15.0 + bmi_very_low = 14.0 + bmi_normal = 18.5 + bmi_overweight = 28.0 + bmi_obese = 32.0 + + if config[CONF_HEIGHT] < 90: + # "BMI is not reasonable + return 0.0 + if metrics[Metric.BMI] <= bmi_very_low: + # Extremely skinny (bmi < 14) + return 30.0 + + fat_scale = config[CONF_SCALE].get_fat_percentage(metrics[Metric.AGE]) + + # Perfect range (bmi >= 18.5 and fat_percentage not high for adults, bmi >= 15.0 for kids + if metrics[Metric.FAT_PERCENTAGE] < fat_scale[2] and ( + (metrics[Metric.BMI] >= bmi_normal and metrics[Metric.AGE] >= 18) + or metrics[Metric.BMI] >= bmi_very_low + and metrics[Metric.AGE] < 18 + ): + return 0.0 + + # Too skinny (bmi between 14 and 15) + if metrics[Metric.BMI] < bmi_low: + return _get_malus(metrics[Metric.BMI], bmi_very_low, bmi_low, 30, 15) + 15.0 + # Skinny (for adults, between 15 and 18.5) + if metrics[Metric.BMI] < bmi_normal and metrics[Metric.AGE] >= 18: + return _get_malus(metrics[Metric.BMI], 15.0, 18.5, 15, 5) + 5.0 + + # Normal or high bmi but too much bodyfat + if ( + metrics[Metric.FAT_PERCENTAGE] >= fat_scale[2] + and (metrics[Metric.BMI] >= bmi_low and metrics[Metric.AGE] < 18) + or (metrics[Metric.BMI] >= bmi_normal and metrics[Metric.AGE] >= 18) + ): + # Obese + if metrics[Metric.BMI] >= bmi_obese: + return 10.0 + # Overweight + if metrics[Metric.BMI] > bmi_overweight: + return _get_malus(metrics[Metric.BMI], 28.0, 25.0, 5, 10) + 5.0 + + return 0.0 + + +def _calculate_body_fat_deduct_score( + config: Mapping[str, Any], metrics: Mapping[Metric, StateType] +) -> float: + scale = config[CONF_SCALE].get_fat_percentage(metrics[Metric.AGE]) + + if config[CONF_GENDER] == Gender.MALE: + best = scale[2] - 3.0 + else: + best = scale[2] - 2.0 + + # Slightly low in fat or low part or normal fat + if scale[0] <= metrics[Metric.FAT_PERCENTAGE] < best: + return 0.0 + if metrics[Metric.FAT_PERCENTAGE] >= scale[3]: + return 20.0 + + # Slightly high body fat + if metrics[Metric.FAT_PERCENTAGE] < scale[3]: + return ( + _get_malus(metrics[Metric.FAT_PERCENTAGE], scale[3], scale[2], 20, 10) + + 10.0 + ) + + # High part of normal fat + if metrics[Metric.FAT_PERCENTAGE] <= scale[2]: + return _get_malus(metrics[Metric.FAT_PERCENTAGE], scale[2], best, 3, 9) + 3.0 + + # Very low in fat + if metrics[Metric.FAT_PERCENTAGE] < scale[0]: + return _get_malus(metrics[Metric.FAT_PERCENTAGE], 1.0, scale[0], 3, 10) + 3.0 + + return 0.0 + + +def _calculate_common_deduct_score( + min_value: float, max_value: float, value: float +) -> float: + if value >= max_value: + return 0.0 + if value < min_value: + return 10.0 + return _get_malus(value, min_value, max_value, 10, 5) + 5.0 + + +def _calculate_muscle_deduct_score( + config: Mapping[str, Any], muscle_mass: float +) -> float: + scale = config[CONF_SCALE].muscle_mass + return _calculate_common_deduct_score(scale[0] - 5.0, scale[0], muscle_mass) + + +def _calculate_water_deduct_score( + config: Mapping[str, Any], water_percentage: float +) -> float: + # 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 + water_percentage_normal = 45 + if config[CONF_GENDER] == Gender.MALE: + water_percentage_normal = 55 + + return _calculate_common_deduct_score( + water_percentage_normal - 5.0, water_percentage_normal, water_percentage + ) + + +def _calculate_bone_deduct_score( + config: Mapping[str, Any], metrics: Mapping[Metric, StateType] +) -> float: + BoneMassEntry = namedtuple("BoneMassEntry", ["min_weight", "bone_mass"]) + + if config[CONF_GENDER] == Gender.MALE: + entries = [ + BoneMassEntry(75, 2.0), + BoneMassEntry(60, 1.9), + BoneMassEntry(0, 1.6), + ] + else: + entries = [ + BoneMassEntry(60, 1.8), + BoneMassEntry(45, 1.5), + BoneMassEntry(0, 1.3), + ] + + bone_mass = entries[-1].bone_mass + for entry in entries: + if metrics[Metric.WEIGHT] >= entry.min_weight: + bone_mass = entry.bone_mass + + return _calculate_common_deduct_score( + bone_mass - 0.3, bone_mass, metrics[Metric.BONE_MASS] + ) + + +def _calculate_body_visceral_deduct_score(visceral_fat: float) -> float: + # No malus = normal; maximum malus (15.0) = very high; malus = between 10 and 15 + # with your visceral fat in your high range + max_data = 15.0 + min_data = 10.0 + + if visceral_fat < min_data: + # 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 + if visceral_fat >= max_data: + return 15.0 + return _get_malus(visceral_fat, max_data, min_data, max_data, min_data) + 10.0 + + +def _calculate_basal_metabolism_deduct_score( + config: Mapping[str, Any], metrics: Mapping[Metric, StateType] +) -> float: + # Get normal BMR + normal_bmr = 20.0 + coefficients = { + Gender.MALE: {30: 21.6, 50: 20.07, 100: 19.35}, + Gender.FEMALE: {30: 21.24, 50: 19.53, 100: 18.63}, + } + + for c_age, coefficient in coefficients[config[CONF_GENDER]].items(): + if metrics[Metric.AGE] < c_age: + normal_bmr = metrics[Metric.WEIGHT] * coefficient + + if metrics[Metric.BMR] >= normal_bmr: + return 0.0 + if metrics[Metric.BMR] <= (normal_bmr - 300): + return 6.0 + # It's really + 5.0 in the app, but it's probably a mistake, should be 3.0 + return _get_malus(metrics[Metric.BMR], normal_bmr - 300, normal_bmr, 6, 3) + 5.0 + + +def _calculate_protein_deduct_score(protein_percentage: float) -> float: + # low: 10,16; normal: 16,17 + # Check limits + if protein_percentage > 17.0: + return 0.0 + if protein_percentage < 10.0: + return 10.0 + + # Return values for low proteins or normal proteins + if protein_percentage <= 16.0: + return _get_malus(protein_percentage, 10.0, 16.0, 10, 5) + 5.0 + if protein_percentage <= 17.0: + return _get_malus(protein_percentage, 16.0, 17.0, 5, 3) + 3.0 + + return 0.0 + + +def get_body_score( + config: Mapping[str, Any], metrics: Mapping[Metric, StateType] +) -> float: + """Calculate the body score.""" + score = 100.0 + score -= _calculate_bmi_deduct_score(config, metrics) + score -= _calculate_body_fat_deduct_score(config, metrics) + score -= _calculate_muscle_deduct_score(config, metrics[Metric.MUSCLE_MASS]) + score -= _calculate_water_deduct_score(config, metrics[Metric.WATER_PERCENTAGE]) + score -= _calculate_body_visceral_deduct_score(metrics[Metric.VISCERAL_FAT]) + score -= _calculate_bone_deduct_score(config, metrics) + score -= _calculate_basal_metabolism_deduct_score(config, metrics) + score -= _calculate_protein_deduct_score(metrics[Metric.PROTEIN_PERCENTAGE]) + + return score diff --git a/custom_components/bodymiscale/metrics/impedance.py b/custom_components/bodymiscale/metrics/impedance.py new file mode 100644 index 0000000..9fd19c1 --- /dev/null +++ b/custom_components/bodymiscale/metrics/impedance.py @@ -0,0 +1,191 @@ +"""Metrics module, which require impedance.""" +from typing import Any, Mapping + +from homeassistant.helpers.typing import StateType + +from ..const import CONF_GENDER, CONF_HEIGHT, CONF_SCALE +from ..models import Gender, Metric +from ..util import check_value_constraints + + +def get_lbm(config: Mapping[str, Any], metrics: Mapping[Metric, StateType]) -> float: + """Get LBM coefficient (with impedance).""" + height = config[CONF_HEIGHT] + lbm = (height * 9.058 / 100) * (height / 100) + lbm += metrics[Metric.WEIGHT] * 0.32 + 12.226 + lbm -= metrics[Metric.IMPEDANCE] * 0.0068 + lbm -= metrics[Metric.AGE] * 0.0542 + + return float(lbm) + + +def get_fat_percentage( + config: Mapping[str, Any], metrics: Mapping[Metric, StateType] +) -> float: + """Get fat percentage.""" + # Set a const to remove from LBM + weight = metrics[Metric.WEIGHT] + coefficient = 1.0 + if config[CONF_GENDER] == Gender.FEMALE: + const = 9.25 if metrics[Metric.AGE] <= 49 else 7.25 + if weight > 60: + coefficient = 0.96 + elif weight < 50: + coefficient = 1.02 + + if config[CONF_HEIGHT] > 160 and (weight < 50 or weight > 60): + coefficient *= 1.03 + else: + const = 0.8 + if weight < 61: + coefficient = 0.98 + + fat_percentage = ( + 1.0 - (((metrics[Metric.LBM] - const) * coefficient) / weight) + ) * 100 + + # Capping body fat percentage + if fat_percentage > 63: + fat_percentage = 75 + return check_value_constraints(fat_percentage, 5, 75) + + +def get_water_percentage( + _: Mapping[str, Any], metrics: Mapping[Metric, StateType] +) -> float: + """Get water percentage.""" + water_percentage = (100 - metrics[Metric.FAT_PERCENTAGE]) * 0.7 + coefficient = 1.02 if water_percentage <= 50 else 0.98 + + # Capping water percentage + if water_percentage * coefficient >= 65: + water_percentage = 75 + return check_value_constraints(water_percentage * coefficient, 35, 75) + + +def get_bone_mass( + config: Mapping[str, Any], metrics: Mapping[Metric, StateType] +) -> float: + """Get bone mass.""" + if config[CONF_GENDER] == Gender.FEMALE: + base = 0.245691014 + else: + base = 0.18016894 + + bone_mass = (base - (metrics[Metric.LBM] * 0.05158)) * -1 + + if bone_mass > 2.2: + bone_mass += 0.1 + else: + bone_mass -= 0.1 + + # Capping bone mass + if config[CONF_GENDER] == Gender.FEMALE and bone_mass > 5.1: + bone_mass = 8 + elif config[CONF_GENDER] == Gender.MALE and bone_mass > 5.2: + bone_mass = 8 + + return check_value_constraints(bone_mass, 0.5, 8) + + +def get_muscle_mass( + config: Mapping[str, Any], metrics: Mapping[Metric, StateType] +) -> float: + """Get muscle mass.""" + weight = metrics[Metric.WEIGHT] + muscle_mass = ( + weight + - ((metrics[Metric.FAT_PERCENTAGE] * 0.01) * weight) + - metrics[Metric.BONE_MASS] + ) + + # Capping muscle mass + if config[CONF_GENDER] == Gender.FEMALE and muscle_mass >= 84: + muscle_mass = 120 + elif config[CONF_GENDER] == Gender.MALE and muscle_mass >= 93.5: + muscle_mass = 120 + + return check_value_constraints(muscle_mass, 10, 120) + + +def get_metabolic_age( + config: Mapping[str, Any], metrics: Mapping[Metric, StateType] +) -> float: + """Get metabolic age.""" + height = config[CONF_HEIGHT] + weight = metrics[Metric.WEIGHT] + age = metrics[Metric.AGE] + impedance = metrics[Metric.IMPEDANCE] + + if config[CONF_GENDER] == Gender.FEMALE: + metabolic_age = ( + (height * -1.1165) + + (weight * 1.5784) + + (age * 0.4615) + + (impedance * 0.0415) + + 83.2548 + ) + else: + metabolic_age = ( + (height * -0.7471) + + (weight * 0.9161) + + (age * 0.4184) + + (impedance * 0.0517) + + 54.2267 + ) + return check_value_constraints(metabolic_age, 15, 80) + + +def get_protein_percentage( + _: Mapping[str, Any], metrics: Mapping[Metric, StateType] +) -> float: + """Get protetin percentage (warn: guessed formula).""" + # Use original algorithm from mi fit (or legacy guess one) + protein_percentage = (metrics[Metric.MUSCLE_MASS] / metrics[Metric.WEIGHT]) * 100 + protein_percentage -= metrics[Metric.WATER_PERCENTAGE] + return check_value_constraints(protein_percentage, 5, 32) + + +def get_fat_mass_to_ideal_weight( + config: Mapping[str, Any], metrics: Mapping[Metric, StateType] +) -> float: + """Get missig mass to ideal weight.""" + weight = metrics[Metric.WEIGHT] + return float( + weight * (config[CONF_SCALE].get_fat_percentage(metrics[Metric.AGE])[2] / 100) + - (weight * (metrics[Metric.FAT_PERCENTAGE] / 100)) + ) + + +def get_body_type( + config: Mapping[str, Any], metrics: Mapping[Metric, StateType] +) -> str: + """Get body type (out of nine possible).""" + fat = metrics[Metric.FAT_PERCENTAGE] + muscle = metrics[Metric.MUSCLE_MASS] + scale = config[CONF_SCALE] + + if fat > scale.get_fat_percentage(metrics[Metric.AGE])[2]: + factor = 0 + elif fat < scale.get_fat_percentage(metrics[Metric.AGE])[1]: + factor = 2 + else: + factor = 1 + + body_type = 1 + (factor * 3) + if muscle > scale.muscle_mass[1]: + body_type = 2 + (factor * 3) + elif muscle < scale.muscle_mass[0]: + body_type = factor * 3 + + return [ + "Obese", + "Overweight", + "Thick-set", + "Lack-exercise", + "Balanced", + "Balanced-muscular", + "Skinny", + "Balanced-skinny", + "Skinny-muscular", + ][body_type] diff --git a/custom_components/bodymiscale/metrics/scale.py b/custom_components/bodymiscale/metrics/scale.py new file mode 100644 index 0000000..590f1d3 --- /dev/null +++ b/custom_components/bodymiscale/metrics/scale.py @@ -0,0 +1,96 @@ +"""Body scale module.""" +from functools import cached_property + +from ..models import Gender + + +class Scale: + """Scale implementation.""" + + def __init__(self, height: int, gender: Gender): + self._height = height + self._gender = gender + + def get_fat_percentage(self, age: int) -> list[float]: + """Get fat percentage.""" + + # The included tables where quite strange, maybe bogus, replaced them with better ones... + scales: list[dict] = [ + { + "min": 0, + "max": 12, + Gender.FEMALE: [12.0, 21.0, 30.0, 34.0], + Gender.MALE: [7.0, 16.0, 25.0, 30.0], + }, + { + "min": 12, + "max": 14, + Gender.FEMALE: [15.0, 24.0, 33.0, 37.0], + Gender.MALE: [7.0, 16.0, 25.0, 30.0], + }, + { + "min": 14, + "max": 16, + Gender.FEMALE: [18.0, 27.0, 36.0, 40.0], + Gender.MALE: [7.0, 16.0, 25.0, 30.0], + }, + { + "min": 16, + "max": 18, + Gender.FEMALE: [20.0, 28.0, 37.0, 41.0], + Gender.MALE: [7.0, 16.0, 25.0, 30.0], + }, + { + "min": 18, + "max": 40, + Gender.FEMALE: [21.0, 28.0, 35.0, 40.0], + Gender.MALE: [11.0, 17.0, 22.0, 27.0], + }, + { + "min": 40, + "max": 60, + Gender.FEMALE: [22.0, 29.0, 36.0, 41.0], + Gender.MALE: [12.0, 18.0, 23.0, 28.0], + }, + { + "min": 60, + "max": 100, + Gender.FEMALE: [23.0, 30.0, 37.0, 42.0], + Gender.MALE: [14.0, 20.0, 25.0, 30.0], + }, + ] + + for scale in scales: + if scale["min"] <= age < scale["max"]: + return scale[self._gender] # type: ignore + + # will never happen but mypy required it + raise NotImplementedError + + @cached_property + def muscle_mass(self) -> list[float]: + """Get muscle mass.""" + scales: list[dict] = [ + { + "min": {Gender.MALE: 170, Gender.FEMALE: 160}, + Gender.FEMALE: [36.5, 42.6], + Gender.MALE: [49.4, 59.5], + }, + { + "min": {Gender.MALE: 160, Gender.FEMALE: 150}, + Gender.FEMALE: [32.9, 37.6], + Gender.MALE: [44.0, 52.5], + }, + { + "min": {Gender.MALE: 0, Gender.FEMALE: 0}, + Gender.FEMALE: [29.1, 34.8], + Gender.MALE: [38.5, 46.6], + }, + ] + + for scale in scales: + if self._height >= scale["min"][self._gender]: + return scale[self._gender] # type: ignore + + # will never happen but mypy required it + raise NotImplementedError diff --git a/custom_components/bodymiscale/metrics/weight.py b/custom_components/bodymiscale/metrics/weight.py new file mode 100644 index 0000000..a2841e2 --- /dev/null +++ b/custom_components/bodymiscale/metrics/weight.py @@ -0,0 +1,64 @@ +"""Metrics module, which require only weight.""" + +from typing import Any, Mapping + +from homeassistant.helpers.typing import StateType + +from custom_components.bodymiscale.const import CONF_GENDER, CONF_HEIGHT + +from ..models import Gender, Metric +from ..util import check_value_constraints + + +def get_bmi(config: Mapping[str, Any], metrics: Mapping[Metric, StateType]) -> float: + """Get MBI.""" + heiht_c = config[CONF_HEIGHT] / 100 + bmi = metrics[Metric.WEIGHT] / (heiht_c * heiht_c) + return check_value_constraints(bmi, 10, 90) + + +def get_bmr(config: Mapping[str, Any], metrics: Mapping[Metric, StateType]) -> float: + """Get BMR.""" + if config[CONF_GENDER] == Gender.FEMALE: + bmr = 864.6 + metrics[Metric.WEIGHT] * 10.2036 + bmr -= config[CONF_HEIGHT] * 0.39336 + bmr -= metrics[Metric.AGE] * 6.204 + + if bmr > 2996: + bmr = 5000 + else: + bmr = 877.8 + metrics[Metric.WEIGHT] * 14.916 + bmr -= config[CONF_HEIGHT] * 0.726 + bmr -= metrics[Metric.AGE] * 8.976 + + if bmr > 2322: + bmr = 5000 + + return check_value_constraints(bmr, 500, 5000) + + +def get_visceral_fat( + config: Mapping[str, Any], metrics: Mapping[Metric, StateType] +) -> float: + """Get Visceral Fat.""" + height = config[CONF_HEIGHT] + weight = metrics[Metric.WEIGHT] + age = metrics[Metric.AGE] + + if config[CONF_GENDER] == Gender.FEMALE: + if weight > (13 - (height * 0.5)) * -1: + subsubcalc = ((height * 1.45) + (height * 0.1158) * height) - 120 + subcalc = weight * 500 / subsubcalc + vfal = (subcalc - 6) + (age * 0.07) + else: + subcalc = 0.691 + (height * -0.0024) + (height * -0.0024) + vfal = (((height * 0.027) - (subcalc * weight)) * -1) + (age * 0.07) - age + else: + if height < weight * 1.6: + subcalc = ((height * 0.4) - (height * (height * 0.0826))) * -1 + vfal = ((weight * 305) / (subcalc + 48)) - 2.9 + (age * 0.15) + else: + subcalc = 0.765 + height * -0.0015 + vfal = (((height * 0.143) - (weight * subcalc)) * -1) + (age * 0.15) - 5.0 + + return check_value_constraints(vfal, 1, 50) diff --git a/custom_components/bodymiscale/models.py b/custom_components/bodymiscale/models.py index 302f1f6..90f1f76 100644 --- a/custom_components/bodymiscale/models.py +++ b/custom_components/bodymiscale/models.py @@ -1,9 +1,49 @@ """Models module.""" from enum import Enum +from .const import ( + ATTR_AGE, + ATTR_BMI, + ATTR_BMR, + ATTR_BODY, + ATTR_BODY_SCORE, + ATTR_BONES, + ATTR_FAT, + ATTR_LBM, + ATTR_METABOLIC, + ATTR_MUSCLE, + ATTR_PROTEIN, + ATTR_VISCERAL, + ATTR_WATER, + CONF_SENSOR_IMPEDANCE, + CONF_SENSOR_WEIGHT, +) + class Gender(str, Enum): """Gender enum.""" MALE = "male" FEMALE = "female" + + +class Metric(str, Enum): + """Metric enum.""" + + STATUS = "status" + AGE = ATTR_AGE + WEIGHT = CONF_SENSOR_WEIGHT + IMPEDANCE = CONF_SENSOR_IMPEDANCE + BMI = ATTR_BMI + BMR = ATTR_BMR + VISCERAL_FAT = ATTR_VISCERAL + LBM = ATTR_LBM + FAT_PERCENTAGE = ATTR_FAT + WATER_PERCENTAGE = ATTR_WATER + BONE_MASS = ATTR_BONES + MUSCLE_MASS = ATTR_MUSCLE + METABOLIC_AGE = ATTR_METABOLIC + PROTEIN_PERCENTAGE = ATTR_PROTEIN + FAT_MASS_2_IDEAL_WEIGHT = "fat_mass_2_ideal_weight" + BODY_TYPE = ATTR_BODY + BODY_SCORE = ATTR_BODY_SCORE diff --git a/custom_components/bodymiscale/sensor.py b/custom_components/bodymiscale/sensor.py new file mode 100644 index 0000000..d32ffc9 --- /dev/null +++ b/custom_components/bodymiscale/sensor.py @@ -0,0 +1,189 @@ +"""Sensor module.""" +from typing import Any, Callable, Mapping, Optional + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import ( + ATTR_BMI, + ATTR_BMILABEL, + ATTR_BMR, + ATTR_BODY, + ATTR_BODY_SCORE, + ATTR_BONES, + ATTR_FAT, + ATTR_IDEAL, + ATTR_LBM, + ATTR_METABOLIC, + ATTR_MUSCLE, + ATTR_PROTEIN, + ATTR_VISCERAL, + ATTR_WATER, + CONF_SENSOR_IMPEDANCE, + CONF_SENSOR_WEIGHT, + DOMAIN, + HANDLERS, +) +from .entity import BodyScaleBaseEntity +from .metrics import BodyScaleMetricsHandler +from .models import Metric +from .util import get_bmi_label, get_ideal_weight + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add entities for passed config_entry in HA.""" + handler: BodyScaleMetricsHandler = hass.data[DOMAIN][HANDLERS][ + config_entry.entry_id + ] + + new_sensors = [ + BodyScaleSensor( + handler, + SensorEntityDescription( + key=ATTR_BMI, + icon="mdi:human", + ), + Metric.BMI, + lambda state, _: {ATTR_BMILABEL: get_bmi_label(state)}, + ), + BodyScaleSensor( + handler, + SensorEntityDescription( + key=ATTR_BMR, + ), + Metric.BMR, + ), + BodyScaleSensor( + handler, + SensorEntityDescription( + key=ATTR_VISCERAL, + ), + Metric.VISCERAL_FAT, + ), + BodyScaleSensor( + handler, + SensorEntityDescription( + key=CONF_SENSOR_WEIGHT, + icon="mdi:weight-kilogram", + native_unit_of_measurement="kg", + ), + Metric.WEIGHT, + lambda _, config: {ATTR_IDEAL: get_ideal_weight(config)}, + ), + ] + + if CONF_SENSOR_IMPEDANCE in handler.config: + new_sensors.extend( + [ + BodyScaleSensor( + handler, + SensorEntityDescription( + key=ATTR_LBM, + ), + Metric.LBM, + ), + BodyScaleSensor( + handler, + SensorEntityDescription( + key=ATTR_FAT, native_unit_of_measurement="%" + ), + Metric.FAT_PERCENTAGE, + ), + BodyScaleSensor( + handler, + SensorEntityDescription( + key=ATTR_PROTEIN, native_unit_of_measurement="%" + ), + Metric.PROTEIN_PERCENTAGE, + ), + BodyScaleSensor( + handler, + SensorEntityDescription( + key=ATTR_WATER, + icon="mdi:water-percent", + native_unit_of_measurement="%", + ), + Metric.WATER_PERCENTAGE, + ), + BodyScaleSensor( + handler, + SensorEntityDescription( + key=ATTR_BONES, + ), + Metric.BONE_MASS, + ), + BodyScaleSensor( + handler, + SensorEntityDescription( + key=ATTR_MUSCLE, + ), + Metric.MUSCLE_MASS, + ), + BodyScaleSensor( + handler, + SensorEntityDescription( + key=ATTR_BODY, + ), + Metric.BODY_TYPE, + ), + BodyScaleSensor( + handler, + SensorEntityDescription( + key=ATTR_METABOLIC, + ), + Metric.METABOLIC_AGE, + ), + BodyScaleSensor( + handler, + SensorEntityDescription( + key=ATTR_BODY_SCORE, + ), + Metric.BODY_SCORE, + ), + ] + ) + + async_add_entities(new_sensors) + + +class BodyScaleSensor(BodyScaleBaseEntity, SensorEntity): # type: ignore[misc] + """Body scale sensor.""" + + def __init__( + self, + handler: BodyScaleMetricsHandler, + entity_description: SensorEntityDescription, + metric: Metric, + get_attributes: Optional[ + Callable[[StateType, Mapping[str, Any]], Mapping[str, Any]] + ] = None, + ): + super().__init__(handler, entity_description) + self.entity_description.state_class = SensorStateClass.MEASUREMENT + self._metric = metric + self._get_attributes = get_attributes + + async def async_added_to_hass(self) -> None: + """Set up the event listeners now that hass is ready.""" + await super().async_added_to_hass() + + def on_value(value: StateType) -> None: + self._attr_native_value = value + if self._get_attributes: + self._attr_extra_state_attributes = self._get_attributes( + self._attr_native_value, self._handler.config + ) + self.async_write_ha_state() + + self.async_on_remove(self._handler.subscribe(self._metric, on_value)) diff --git a/custom_components/bodymiscale/translations/en.json b/custom_components/bodymiscale/translations/en.json index 12bc0b4..23f9651 100644 --- a/custom_components/bodymiscale/translations/en.json +++ b/custom_components/bodymiscale/translations/en.json @@ -2,6 +2,7 @@ "config": { "error": { "already_configured": "Already configured", + "height_limit": "Height is too high (limit: 220 cm)", "invalid_date": "Invalid date" }, "step": { diff --git a/custom_components/bodymiscale/translations/fr.json b/custom_components/bodymiscale/translations/fr.json index f9b73b2..ed5bac0 100644 --- a/custom_components/bodymiscale/translations/fr.json +++ b/custom_components/bodymiscale/translations/fr.json @@ -1,21 +1,27 @@ { "config": { "error": { + "already_configured": "Déjà configuré", + "height_limit": "La taille est trop élevée (limite : 220 cm)", "invalid_date": "Date non valide" }, "step": { - "user": { + "options": { "data": { - "birthday": "Anniversaire", - "gender": "Genre", "height": "Taille", "impedance": "Capteur d' impédance", - "name": "Nom", "weight": "Capteur de poids" }, "data_description": { "impedance": "Si votre balance ne fournit pas l' impédance, laissez ce champ vide." } + }, + "user": { + "data": { + "birthday": "Date d'anniversaire", + "gender": "Genre", + "name": "Nom" + } } } }, diff --git a/custom_components/bodymiscale/translations/pt-BR.json b/custom_components/bodymiscale/translations/pt-BR.json index c1805f3..16153fa 100644 --- a/custom_components/bodymiscale/translations/pt-BR.json +++ b/custom_components/bodymiscale/translations/pt-BR.json @@ -1,27 +1,27 @@ { - "state": { - "_": { - "ok": "OK", - "problem": "Problema" - } - }, "config": { "error": { + "already_configured": "Já configurado", + "height_limit": "Altura é muito alta (limite: 220 cm)", "invalid_date": "Data inválida" }, "step": { - "user": { + "options": { "data": { - "name": "Nome", "height": "Altura", - "birthday": "Data de nascimento", - "gender": "Gênero", - "weight": "Sensor de peso", - "impedance": "Sensor de impedância" + "impedance": "Sensor de impedância", + "weight": "Sensor de peso" }, "data_description": { "impedance": "Se sua balança não fornecer a impedância, deixe este campo vazio." } + }, + "user": { + "data": { + "birthday": "Data de nascimento", + "gender": "Gênero", + "name": "Nome" + } } } }, @@ -30,13 +30,19 @@ "init": { "data": { "height": "Altura", - "weight": "Sensor de peso", - "impedance": "Sensor de impedância" + "impedance": "Sensor de impedância", + "weight": "Sensor de peso" }, "data_description": { "impedance": "Se sua balança não fornecer a impedância, deixe este campo vazio." } } } + }, + "state": { + "_": { + "ok": "OK", + "problem": "Problema" + } } } diff --git a/custom_components/bodymiscale/util.py b/custom_components/bodymiscale/util.py new file mode 100644 index 0000000..9110557 --- /dev/null +++ b/custom_components/bodymiscale/util.py @@ -0,0 +1,43 @@ +"""Util module.""" + + +from typing import Any, Mapping + +from .const import CONF_GENDER, CONF_HEIGHT +from .models import Gender + + +def check_value_constraints(value: float, minimum: float, maximum: float) -> float: + """Set the value to a boundary if it overflows.""" + if value < minimum: + return minimum + if value > maximum: + return maximum + return value + + +def get_ideal_weight(config: Mapping[str, Any]) -> float: + """Get ideal weight (just doing a reverse BMI, should be something better).""" + if config[CONF_GENDER] == Gender.FEMALE: + ideal = float(config[CONF_HEIGHT] - 70) * 0.6 + else: + ideal = float(config[CONF_HEIGHT] - 80) * 0.7 + + return round(ideal, 0) + + +def get_bmi_label(bmi: float) -> str: # pylint: disable=too-many-return-statements + """Get BMI label.""" + if bmi < 18.5: + return "Underweight" + if bmi < 25: + return "Normal or Healthy Weight" + if bmi < 27: + return "Slight overweight" + if bmi < 30: + return "Overweight" + if bmi < 35: + return "Moderate obesity" + if bmi < 40: + return "Severe obesity" + return "Massive obesity" diff --git a/requirements.txt b/requirements.txt index 66e17ed..b15bc52 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ +cachetools==5.1.0 homeassistant>=2022.4.0.b0 mypy==0.960 pre-commit==2.19.0 pylint==2.13.9 - +types-cachetools #pytest-homeassistant-custom-component==0.4.4