├── .prettierrc ├── custom_components ├── __init__.py └── bodymiscale │ ├── manifest.json │ ├── models.py │ ├── const.py │ ├── entity.py │ ├── util.py │ ├── metrics │ ├── weight.py │ ├── scale.py │ ├── impedance.py │ └── body_score.py │ ├── translations │ ├── zh-Hans.json │ ├── zh-Hant.json │ ├── de.json │ ├── en.json │ ├── da.json │ ├── sk.json │ ├── pl.json │ ├── es.json │ ├── pt-BR.json │ ├── ru.json │ ├── fr.json │ ├── it.json │ └── ro.json │ ├── config_flow.py │ ├── __init__.py │ └── sensor.py ├── .gitattributes ├── image ├── icon.PNG ├── logo.png ├── logo@2x.png ├── initiale.png ├── version 0.02.png └── version 0.0.2 with exemple problem impedance low.png ├── hacs.json ├── example_config ├── screenshot_phone_notification.jpg ├── esphome_base_configuration.yaml ├── README.md ├── weight_impedance_update.yaml └── esphome_configuration.yaml ├── requirements.txt ├── scripts ├── install │ └── pip_packages ├── coverage ├── test ├── lint ├── setup ├── run-in-env.sh └── develop ├── bandit.yaml ├── .vscode ├── settings.json ├── tasks.json └── launch.json ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── codeql-analysis.yml ├── mypy.ini ├── setup.cfg ├── .yamllint ├── .gitignore ├── .pre-commit-config.yaml ├── pylintrc ├── .devcontainer.json ├── CHANGELOG.md ├── LICENSE └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsonRecursiveSort": true 3 | } 4 | -------------------------------------------------------------------------------- /custom_components/__init__.py: -------------------------------------------------------------------------------- 1 | """Custom components module.""" 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /image/icon.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dckiller51/bodymiscale/HEAD/image/icon.PNG -------------------------------------------------------------------------------- /image/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dckiller51/bodymiscale/HEAD/image/logo.png -------------------------------------------------------------------------------- /image/logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dckiller51/bodymiscale/HEAD/image/logo@2x.png -------------------------------------------------------------------------------- /image/initiale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dckiller51/bodymiscale/HEAD/image/initiale.png -------------------------------------------------------------------------------- /image/version 0.02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dckiller51/bodymiscale/HEAD/image/version 0.02.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "homeassistant": "2023.9.0", 3 | "name": "Bodymiscale", 4 | "render_readme": true 5 | } 6 | -------------------------------------------------------------------------------- /example_config/screenshot_phone_notification.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dckiller51/bodymiscale/HEAD/example_config/screenshot_phone_notification.jpg -------------------------------------------------------------------------------- /image/version 0.0.2 with exemple problem impedance low.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dckiller51/bodymiscale/HEAD/image/version 0.0.2 with exemple problem impedance low.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | cachetools==6.2.0 2 | homeassistant>=2024.3.3 3 | mypy==1.18.1 4 | pre-commit==4.3.0 5 | pylint==3.3.8 6 | types-cachetools 7 | #pytest-homeassistant-custom-component==0.4.4 8 | -------------------------------------------------------------------------------- /scripts/install/pip_packages: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | python3 -m pip \ 6 | install \ 7 | --upgrade \ 8 | --disable-pip-version-check \ 9 | "${@}" 10 | 11 | -------------------------------------------------------------------------------- /scripts/coverage: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | bash scripts/test > /dev/null 8 | python3 -m \ 9 | coverage \ 10 | report \ 11 | --skip-covered -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | python3 -m \ 8 | pytest \ 9 | tests \ 10 | -rxf -x -v -l \ 11 | --cov=./ \ 12 | --cov-report=xml 13 | 14 | -------------------------------------------------------------------------------- /scripts/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | pre-commit install-hooks; 8 | pre-commit run --hook-stage manual --all-files; 9 | 10 | #bellybutton lint 11 | 12 | #vulture . --min-confidence 75 --ignore-names policy 13 | -------------------------------------------------------------------------------- /scripts/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | scripts/install/pip_packages "pip" 8 | scripts/install/pip_packages setuptools wheel 9 | scripts/install/pip_packages --requirement requirements.txt 10 | pre-commit install 11 | -------------------------------------------------------------------------------- /bandit.yaml: -------------------------------------------------------------------------------- 1 | # https://bandit.readthedocs.io/en/latest/config.html 2 | 3 | tests: 4 | - B103 5 | - B108 6 | - B306 7 | - B307 8 | - B313 9 | - B314 10 | - B315 11 | - B316 12 | - B317 13 | - B318 14 | - B319 15 | - B320 16 | - B601 17 | - B602 18 | - B604 19 | - B608 20 | - B609 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "*.yaml": "home-assistant" 4 | }, 5 | "python-envs.defaultEnvManager": "ms-python.python:system", 6 | "python-envs.pythonProjects": [], 7 | "python.linting.enabled": true, 8 | "python.linting.pylintEnabled": true, 9 | "python.pythonPath": "/usr/local/bin/python" 10 | } 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | 9 | # Maintain dependencies for pip 10 | - package-ecosystem: "pip" 11 | directory: "/" 12 | schedule: 13 | interval: "daily" 14 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "bodymiscale", 3 | "name": "BodyMiScale", 4 | "codeowners": [ 5 | "@dckiller51", 6 | "@edenhaus" 7 | ], 8 | "config_flow": true, 9 | "documentation": "https://github.com/dckiller51/bodymiscale", 10 | "iot_class": "calculated", 11 | "issue_tracker": "https://github.com/dckiller51/bodymiscale/issues", 12 | "loggers": [ 13 | "custom_components.bodymiscale" 14 | ], 15 | "requirements": [ 16 | "cachetools==5.3.0" 17 | ], 18 | "version": "2025.4.0" 19 | } 20 | -------------------------------------------------------------------------------- /scripts/run-in-env.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eu 3 | 4 | # Activate pyenv and virtualenv if present, then run the specified command 5 | 6 | # pyenv, pyenv-virtualenv 7 | if [ -s .python-version ]; then 8 | PYENV_VERSION=$(head -n 1 .python-version) 9 | export PYENV_VERSION 10 | fi 11 | 12 | # other common virtualenvs 13 | my_path=$(git rev-parse --show-toplevel) 14 | 15 | for venv in venv .venv .; do 16 | if [ -f "${my_path}/${venv}/bin/activate" ]; then 17 | . "${my_path}/${venv}/bin/activate" 18 | fi 19 | done 20 | 21 | exec "$@" 22 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.13 3 | show_error_codes = true 4 | follow_imports = silent 5 | ignore_missing_imports = true 6 | strict_equality = true 7 | warn_incomplete_stub = true 8 | warn_redundant_casts = true 9 | warn_unused_configs = true 10 | warn_unused_ignores = true 11 | check_untyped_defs = true 12 | disallow_incomplete_defs = true 13 | disallow_subclassing_any = true 14 | disallow_untyped_calls = true 15 | disallow_untyped_decorators = true 16 | disallow_untyped_defs = true 17 | no_implicit_optional = true 18 | warn_return_any = true 19 | warn_unreachable = true 20 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build 3 | doctests = True 4 | # To work with Black 5 | max-line-length = 88 6 | # E501: line too long 7 | # W503: Line break occurred before a binary operator 8 | # E203: Whitespace before ':' 9 | # D202 No blank lines allowed after function docstring 10 | # D107 Missing docstring in __init__ 11 | ignore = 12 | E501, 13 | W503, 14 | E203, 15 | D202, 16 | D107 17 | 18 | [isort] 19 | # https://github.com/timothycrosley/isort 20 | # https://github.com/timothycrosley/isort/wiki/isort-Settings 21 | profile = black -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://code.visualstudio.com/docs/editor/tasks 2 | { 3 | "tasks": [ 4 | { 5 | "command": "scripts/develop", 6 | "label": "Run Home Assistant", 7 | "problemMatcher": [], 8 | "type": "shell" 9 | }, 10 | { 11 | "command": "scripts/setup", 12 | "label": "Upgrade environment", 13 | "problemMatcher": [], 14 | "type": "shell" 15 | }, 16 | { 17 | "command": "scripts/test", 18 | "label": "Run tests", 19 | "problemMatcher": [], 20 | "type": "shell" 21 | }, 22 | { 23 | "command": "scripts/lint", 24 | "label": "Run lint checks", 25 | "problemMatcher": [], 26 | "type": "shell" 27 | } 28 | ], 29 | "version": "2.0.0" 30 | } 31 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 2 | { 3 | "configurations": [ 4 | { 5 | "host": "localhost", 6 | "justMyCode": false, 7 | // Example of attaching to local debug server 8 | "name": "Python: Attach Local", 9 | "pathMappings": [ 10 | { 11 | "localRoot": "${workspaceFolder}", 12 | "remoteRoot": "." 13 | } 14 | ], 15 | "port": 5678, 16 | "request": "attach", 17 | "type": "python" 18 | }, 19 | { 20 | "host": "homeassistant.local", 21 | // Example of attaching to my production server 22 | "name": "Python: Attach Remote", 23 | "pathMappings": [ 24 | { 25 | "localRoot": "${workspaceFolder}", 26 | "remoteRoot": "/usr/src/homeassistant" 27 | } 28 | ], 29 | "port": 5678, 30 | "request": "attach", 31 | "type": "python" 32 | } 33 | ], 34 | "version": "0.2.0" 35 | } 36 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/models.py: -------------------------------------------------------------------------------- 1 | """Models module.""" 2 | 3 | from enum import Enum 4 | 5 | from .const import ( 6 | ATTR_AGE, 7 | ATTR_BMI, 8 | ATTR_BMR, 9 | ATTR_BODY, 10 | ATTR_BODY_SCORE, 11 | ATTR_BONES, 12 | ATTR_FAT, 13 | ATTR_LBM, 14 | ATTR_METABOLIC, 15 | ATTR_MUSCLE, 16 | ATTR_PROTEIN, 17 | ATTR_VISCERAL, 18 | ATTR_WATER, 19 | CONF_SENSOR_IMPEDANCE, 20 | CONF_SENSOR_LAST_MEASUREMENT_TIME, 21 | CONF_SENSOR_WEIGHT, 22 | ) 23 | 24 | 25 | class Gender(str, Enum): 26 | """Gender enum.""" 27 | 28 | MALE = "male" 29 | FEMALE = "female" 30 | 31 | 32 | class Metric(str, Enum): 33 | """Metric enum.""" 34 | 35 | STATUS = "status" 36 | AGE = ATTR_AGE 37 | WEIGHT = CONF_SENSOR_WEIGHT 38 | IMPEDANCE = CONF_SENSOR_IMPEDANCE 39 | LAST_MEASUREMENT_TIME = CONF_SENSOR_LAST_MEASUREMENT_TIME 40 | BMI = ATTR_BMI 41 | BMR = ATTR_BMR 42 | VISCERAL_FAT = ATTR_VISCERAL 43 | LBM = ATTR_LBM 44 | FAT_PERCENTAGE = ATTR_FAT 45 | WATER_PERCENTAGE = ATTR_WATER 46 | BONE_MASS = ATTR_BONES 47 | MUSCLE_MASS = ATTR_MUSCLE 48 | METABOLIC_AGE = ATTR_METABOLIC 49 | PROTEIN_PERCENTAGE = ATTR_PROTEIN 50 | FAT_MASS_2_IDEAL_WEIGHT = "fat_mass_2_ideal_weight" 51 | BODY_TYPE = ATTR_BODY 52 | BODY_SCORE = ATTR_BODY_SCORE 53 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | # .yamllint configuration file 2 | ignore: 3 | - example_config/* 4 | 5 | rules: 6 | braces: 7 | level: error 8 | min-spaces-inside: 0 9 | max-spaces-inside: 1 10 | min-spaces-inside-empty: -1 11 | max-spaces-inside-empty: -1 12 | brackets: 13 | level: error 14 | min-spaces-inside: 0 15 | max-spaces-inside: 0 16 | min-spaces-inside-empty: -1 17 | max-spaces-inside-empty: -1 18 | colons: 19 | level: error 20 | max-spaces-before: 0 21 | max-spaces-after: 1 22 | commas: 23 | level: error 24 | max-spaces-before: 0 25 | min-spaces-after: 1 26 | max-spaces-after: 1 27 | comments: 28 | level: error 29 | require-starting-space: true 30 | min-spaces-from-content: 2 31 | comments-indentation: 32 | level: error 33 | document-end: 34 | level: error 35 | present: false 36 | document-start: 37 | level: error 38 | present: false 39 | empty-lines: 40 | level: error 41 | max: 1 42 | max-start: 0 43 | max-end: 1 44 | hyphens: 45 | level: error 46 | max-spaces-after: 1 47 | indentation: 48 | level: error 49 | spaces: 2 50 | indent-sequences: true 51 | check-multi-line-strings: false 52 | key-duplicates: 53 | level: error 54 | line-length: disable 55 | new-line-at-end-of-file: 56 | level: error 57 | new-lines: 58 | level: error 59 | type: unix 60 | trailing-spaces: 61 | level: error 62 | truthy: disable 63 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | pull_request: ~ 11 | schedule: 12 | - cron: "0 0 * * *" 13 | 14 | env: 15 | DEFAULT_PYTHON: "3.13" 16 | 17 | jobs: 18 | validate-hacs: 19 | runs-on: "ubuntu-latest" 20 | name: Validate with HACS 21 | steps: 22 | - uses: "actions/checkout@v5" 23 | 24 | - name: HACS validation 25 | uses: "hacs/action@main" 26 | with: 27 | category: "integration" 28 | 29 | validate-hassfest: 30 | runs-on: "ubuntu-latest" 31 | name: Validate with Hassfest 32 | steps: 33 | - uses: "actions/checkout@v5" 34 | 35 | - name: Hassfest validation 36 | uses: "home-assistant/actions/hassfest@master" 37 | 38 | code-quality: 39 | runs-on: "ubuntu-latest" 40 | name: Check code quality 41 | steps: 42 | - uses: "actions/checkout@v5" 43 | - name: Set up Python ${{ env.DEFAULT_PYTHON }} 44 | id: python 45 | uses: actions/setup-python@v6 46 | with: 47 | python-version: ${{ env.DEFAULT_PYTHON }} 48 | cache: "pip" 49 | - name: Install dependencies 50 | run: | 51 | pip install -r requirements.txt 52 | pip install mypy pylint 53 | - name: Run mypy 54 | run: mypy custom_components/ 55 | - name: Pylint review 56 | run: pylint custom_components/ 57 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/const.py: -------------------------------------------------------------------------------- 1 | """Constants for bodymiscale.""" 2 | 3 | from homeassistant.const import Platform 4 | 5 | MIN_REQUIRED_HA_VERSION = "2023.9.0" 6 | NAME = "BodyMiScale" 7 | DOMAIN = "bodymiscale" 8 | VERSION = "2025.9.0" 9 | 10 | ISSUE_URL = "https://github.com/dckiller51/bodymiscale/issues" 11 | 12 | CONF_BIRTHDAY = "birthday" 13 | CONF_GENDER = "gender" 14 | CONF_HEIGHT = "height" 15 | CONF_SENSOR_IMPEDANCE = "impedance" 16 | CONF_SENSOR_LAST_MEASUREMENT_TIME = "last_measurement_time" 17 | CONF_SENSOR_WEIGHT = "weight" 18 | CONF_SCALE = "scale" 19 | 20 | ATTR_AGE = "age" 21 | ATTR_BMI = "bmi" 22 | ATTR_BMILABEL = "bmi_label" 23 | ATTR_BMR = "basal_metabolism" 24 | ATTR_BODY = "body_type" 25 | ATTR_BODY_SCORE = "body_score" 26 | ATTR_BONES = "bone_mass" 27 | ATTR_FAT = "body_fat" 28 | ATTR_FATMASSTOGAIN = "fat_mass_to_gain" 29 | ATTR_FATMASSTOLOSE = "fat_mass_to_lose" 30 | ATTR_IDEAL = "ideal" 31 | ATTR_LBM = "lean_body_mass" 32 | ATTR_METABOLIC = "metabolic_age" 33 | ATTR_MUSCLE = "muscle_mass" 34 | ATTR_PROBLEM = "problem" 35 | ATTR_PROTEIN = "protein" 36 | ATTR_VISCERAL = "visceral_fat" 37 | ATTR_WATER = "water" 38 | 39 | UNIT_POUNDS = "lb" 40 | 41 | PROBLEM_NONE = "none" 42 | 43 | STARTUP_MESSAGE = f""" 44 | ------------------------------------------------------------------- 45 | {NAME} 46 | Version: {VERSION} 47 | This is a custom integration! 48 | If you have any issues with this you need to open an issue here: 49 | {ISSUE_URL} 50 | ------------------------------------------------------------------- 51 | """ 52 | 53 | CONSTRAINT_HEIGHT_MIN = 50 54 | CONSTRAINT_HEIGHT_MAX = 220 55 | CONSTRAINT_IMPEDANCE_MIN = 50 56 | CONSTRAINT_IMPEDANCE_MAX = 3000 57 | CONSTRAINT_WEIGHT_MIN = 10 58 | CONSTRAINT_WEIGHT_MAX = 200 59 | 60 | MIN = "min" 61 | MAX = "max" 62 | COMPONENT = "component" 63 | HANDLERS = "handlers" 64 | 65 | PLATFORMS: set[Platform] = {Platform.SENSOR} 66 | UPDATE_DELAY = 2.0 67 | -------------------------------------------------------------------------------- /scripts/develop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd "$(dirname "$0")/.." 6 | 7 | if [ ! -f "${PWD}/config/configuration.yaml" ]; then 8 | mkdir -p "${PWD}/config" 9 | hass --config "${PWD}/config" --script ensure_config 10 | echo "Creating default configuration." 11 | echo " 12 | default_config: 13 | 14 | logger: 15 | default: info 16 | logs: 17 | custom_components.bodymiscale: debug 18 | 19 | # Example configuration.yaml entry 20 | input_number: 21 | weight: 22 | name: Weight 23 | initial: 70 24 | <<: &weight_options 25 | min: 10 26 | max: 200 27 | step: 0.1 28 | impedance: 29 | name: Impedance 30 | initial: 400 31 | <<: &impedance_options 32 | min: 0 33 | max: 3000 34 | step: 1 35 | 36 | weight_2: 37 | name: Weight 2 38 | initial: 80 39 | <<: *weight_options 40 | impedance_2: 41 | name: Impedance 2 42 | initial: 800 43 | <<: *impedance_options 44 | 45 | template: 46 | - sensor: 47 | - name: Weight 48 | state: \"{{ states('input_number.weight') }}\" 49 | unit_of_measurement: "kg" 50 | - name: Impedance 51 | state: \"{{ states('input_number.impedance') }}\" 52 | unit_of_measurement: "ohm" 53 | - name: Weight 2 54 | state: \"{{ states('input_number.weight_2') }}\" 55 | unit_of_measurement: "kg" 56 | - name: Impedance 2 57 | state: \"{{ states('input_number.impedance_2') }}\" 58 | unit_of_measurement: "ohm" 59 | 60 | # If you need to debug uncomment the line below (doc: https://www.home-assistant.io/integrations/debugpy/) 61 | debugpy: 62 | # wait: true 63 | " >> "${PWD}/config/configuration.yaml" 64 | fi 65 | 66 | # Set the python path to include our custom_components directory 67 | export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components" 68 | 69 | # Start Home Assistant 70 | hass --config "${PWD}/config" --debug -------------------------------------------------------------------------------- /custom_components/bodymiscale/entity.py: -------------------------------------------------------------------------------- 1 | """Bodymiscale entity module.""" 2 | 3 | from homeassistant.const import CONF_NAME 4 | from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo 5 | from homeassistant.helpers.entity import Entity, EntityDescription 6 | from homeassistant.helpers.typing import UNDEFINED 7 | 8 | from .const import DOMAIN, VERSION 9 | from .metrics import BodyScaleMetricsHandler 10 | 11 | 12 | class BodyScaleBaseEntity(Entity): 13 | """Body scale base entity.""" 14 | 15 | _attr_should_poll = False 16 | _attr_has_entity_name = True 17 | 18 | def __init__( 19 | self, 20 | handler: BodyScaleMetricsHandler, 21 | entity_description: EntityDescription | None = None, 22 | ): 23 | """Initialize the entity.""" 24 | super().__init__() 25 | self._handler = handler 26 | if entity_description: 27 | self.entity_description = entity_description 28 | elif not hasattr(self, "entity_description"): 29 | raise ValueError( 30 | '"entity_description" must be either set as class variable or passed on init!' 31 | ) 32 | 33 | if not self.entity_description.key: 34 | raise ValueError('"entity_description.key" must be either set!') 35 | 36 | name = handler.config[CONF_NAME] 37 | self._attr_unique_id = "_".join([DOMAIN, name, self.entity_description.key]) 38 | 39 | if self.entity_description.name == UNDEFINED: 40 | # Name not provided... get it from the key 41 | self._attr_name = self.entity_description.key.replace("_", " ").capitalize() 42 | else: 43 | self._attr_name = ( 44 | self._handler.config[CONF_NAME].replace("_", " ").capitalize() 45 | ) 46 | self._attr_device_info = DeviceInfo( 47 | entry_type=DeviceEntryType.SERVICE, 48 | name=self._handler.config[CONF_NAME], 49 | sw_version=VERSION, 50 | identifiers={(DOMAIN, self._handler.config_entry_id)}, 51 | ) 52 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/util.py: -------------------------------------------------------------------------------- 1 | """Util module.""" 2 | 3 | from collections.abc import Mapping 4 | from datetime import datetime 5 | from typing import Any 6 | 7 | from .const import CONF_GENDER, CONF_HEIGHT 8 | from .models import Gender 9 | 10 | 11 | def check_value_constraints(value: float, minimum: float, maximum: float) -> float: 12 | """Set the value to a boundary if it overflows.""" 13 | if value < minimum: 14 | return minimum 15 | if value > maximum: 16 | return maximum 17 | return value 18 | 19 | 20 | def to_float(val: Any, default: float = 0.0) -> float: 21 | """Convert value to float if possible, otherwise return a default.""" 22 | if isinstance(val, (int, float)): 23 | return float(val) 24 | if isinstance(val, str): 25 | try: 26 | return float(val) 27 | except ValueError: 28 | return default 29 | return default 30 | 31 | 32 | def get_ideal_weight(config: Mapping[str, Any]) -> float: 33 | """Get ideal weight (just doing a reverse BMI, should be something better).""" 34 | if config[CONF_GENDER] == Gender.FEMALE: 35 | ideal = float(config[CONF_HEIGHT] - 70) * 0.6 36 | else: 37 | ideal = float(config[CONF_HEIGHT] - 80) * 0.7 38 | 39 | return round(ideal, 0) 40 | 41 | 42 | def get_bmi_label(bmi: float) -> str: 43 | """Get BMI label.""" 44 | if bmi < 18.5: 45 | return "underweight" 46 | if bmi < 25: 47 | return "normal_or_healthy_weight" 48 | if bmi < 27: 49 | return "slight_overweight" 50 | if bmi < 30: 51 | return "overweight" 52 | if bmi < 35: 53 | return "moderate_obesity" 54 | if bmi < 40: 55 | return "severe_obesity" 56 | return "massive_obesity" 57 | 58 | 59 | def get_age(date: str) -> int: 60 | """Get current age from birthdate.""" 61 | born = datetime.strptime(date, "%Y-%m-%d") 62 | today = datetime.today() 63 | age = today.year - born.year 64 | if (today.month, today.day) < (born.month, born.day): 65 | age -= 1 66 | return age 67 | -------------------------------------------------------------------------------- /example_config/esphome_base_configuration.yaml: -------------------------------------------------------------------------------- 1 | esphome: 2 | name: xiaomi-miscale 3 | friendly_name: xiaomi_miscale 4 | 5 | esp32: 6 | board: esp32dev 7 | framework: 8 | type: arduino 9 | 10 | wifi: 11 | ssid: !secret ssid 12 | password: !secret wpa2 13 | fast_connect: true 14 | 15 | captive_portal: 16 | 17 | logger: 18 | 19 | api: 20 | 21 | ota: 22 | - platform: esphome 23 | password: !secret ota_password 24 | 25 | esp32_ble_tracker: 26 | 27 | time: 28 | - platform: homeassistant 29 | id: esptime 30 | 31 | sensor: 32 | # - platform: template 33 | # name: "Xiaomi Mi Scale v1 Last Weight Time" 34 | # id: xiaomi_v1_last_weight_time 35 | # lambda: 'return id(xiaomi_v1_last_weight_time).state;' 36 | # device_class: timestamp 37 | 38 | - platform: template 39 | name: "Xiaomi Mi Scale v2 Last Weight Time" 40 | id: xiaomi_v2_last_weight_time 41 | lambda: "return id(xiaomi_v2_last_weight_time).state;" 42 | device_class: timestamp 43 | 44 | # - platform: xiaomi_miscale 45 | # mac_address: 'C8:47:8C:9F:7B:0A' 46 | # weight: 47 | # name: "Xiaomi Mi Scale v1 Weight" 48 | # name: "Xiaomi Mi Scale v1 Weight" 49 | # id: xiaomi_v1_weight 50 | # filters: 51 | # - timeout: 52 | # timeout: 30s 53 | # value: !lambda return 0; 54 | # on_value: 55 | # then: 56 | # - sensor.template.publish: 57 | # id: xiaomi_v1_last_weight_time 58 | # state: !lambda 'return id(esptime).now().timestamp;' 59 | 60 | - platform: xiaomi_miscale 61 | mac_address: "5C:CA:D3:70:D4:A2" 62 | weight: 63 | name: "Xiaomi Mi Scale v2 Weight" 64 | id: xiaomi_v2_weight 65 | filters: 66 | - timeout: 67 | timeout: 30s 68 | value: !lambda return 0; 69 | on_value: 70 | then: 71 | - sensor.template.publish: 72 | id: xiaomi_v2_last_weight_time 73 | state: !lambda "return id(esptime).now().timestamp;" 74 | impedance: 75 | name: "Xiaomi Mi Scale v2 Impedance" 76 | filters: 77 | - timeout: 78 | timeout: 30s 79 | value: !lambda return 0; 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # celery beat schedule file 95 | celerybeat-schedule 96 | 97 | # SageMath parsed files 98 | *.sage.py 99 | 100 | # Environments 101 | .env 102 | .venv 103 | env/ 104 | venv/ 105 | ENV/ 106 | env.bak/ 107 | venv.bak/ 108 | 109 | # Spyder project settings 110 | .spyderproject 111 | .spyproject 112 | 113 | # Rope project settings 114 | .ropeproject 115 | 116 | # mkdocs documentation 117 | /site 118 | 119 | # mypy 120 | .mypy_cache/ 121 | .dmypy.json 122 | dmypy.json 123 | 124 | # Pyre type checker 125 | .pyre/ 126 | 127 | .idea 128 | 129 | /config -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | skip: 3 | - mypy 4 | - pylint 5 | 6 | default_language_version: 7 | python: python3.13 8 | 9 | repos: 10 | - repo: https://github.com/asottile/pyupgrade 11 | rev: v3.21.1 12 | hooks: 13 | - id: pyupgrade 14 | args: [--py310-plus] 15 | - repo: https://github.com/psf/black-pre-commit-mirror 16 | rev: 25.11.0 17 | hooks: 18 | - id: black 19 | args: 20 | - --safe 21 | - --quiet 22 | <<: &python-files-with-tests 23 | files: ^((custom_components|tests)/.+)?[^/]+\.py$ 24 | - repo: https://github.com/PyCQA/flake8 25 | rev: 7.3.0 26 | hooks: 27 | - id: flake8 28 | additional_dependencies: 29 | - flake8-docstrings==1.6.0 30 | - pydocstyle==6.1.1 31 | <<: &python-files 32 | files: ^(custom_components/.+)?[^/]+\.py$ 33 | - repo: https://github.com/PyCQA/bandit 34 | rev: 1.8.6 35 | hooks: 36 | - id: bandit 37 | args: 38 | - --quiet 39 | - --format=custom 40 | - --configfile=bandit.yaml 41 | <<: *python-files-with-tests 42 | - repo: https://github.com/PyCQA/isort 43 | rev: 7.0.0 44 | hooks: 45 | - id: isort 46 | - repo: https://github.com/pre-commit/pre-commit-hooks 47 | rev: v6.0.0 48 | hooks: 49 | - id: check-executables-have-shebangs 50 | - id: check-merge-conflict 51 | - id: detect-private-key 52 | - id: no-commit-to-branch 53 | - id: requirements-txt-fixer 54 | - id: mixed-line-ending 55 | args: 56 | - --fix=lf 57 | stages: [manual] 58 | - repo: https://github.com/pre-commit/mirrors-prettier 59 | rev: v4.0.0-alpha.8 60 | hooks: 61 | - id: prettier 62 | additional_dependencies: 63 | - prettier@2.7.1 64 | - prettier-plugin-sort-json@0.0.3 65 | exclude_types: 66 | - python 67 | exclude: manifest\.json$ 68 | - repo: https://github.com/adrienverge/yamllint.git 69 | rev: v1.37.1 70 | hooks: 71 | - id: yamllint 72 | - repo: local 73 | hooks: 74 | # Run mypy through our wrapper script in order to get the possible 75 | # pyenv and/or virtualenv activated; it may not have been e.g. if 76 | # committing from a GUI tool that was not launched from an activated 77 | # shell. 78 | - id: mypy 79 | name: Check with mypy 80 | entry: scripts/run-in-env.sh mypy 81 | language: script 82 | types: [python] 83 | <<: *python-files 84 | - id: pylint 85 | name: Check with pylint 86 | entry: scripts/run-in-env.sh pylint 87 | language: script 88 | types: [python] 89 | <<: *python-files 90 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: 17 | - dev 18 | pull_request: 19 | # The branches below must be a subset of the branches above 20 | branches: 21 | - dev 22 | schedule: 23 | - cron: "20 10 * * 0" 24 | 25 | jobs: 26 | analyze: 27 | name: Analyze 28 | runs-on: ubuntu-latest 29 | permissions: 30 | actions: read 31 | contents: read 32 | security-events: write 33 | 34 | strategy: 35 | fail-fast: false 36 | matrix: 37 | language: ["python"] 38 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 39 | # Learn more: 40 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 41 | 42 | steps: 43 | - name: Checkout repository 44 | uses: actions/checkout@v5 45 | 46 | # Initializes the CodeQL tools for scanning. 47 | - name: Initialize CodeQL 48 | uses: github/codeql-action/init@v3 49 | with: 50 | languages: ${{ matrix.language }} 51 | # If you wish to specify custom queries, you can do so here or in a config file. 52 | # By default, queries listed here will override any specified in a config file. 53 | # Prefix the list here with "+" to use these queries and those in the config file. 54 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v3 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 https://git.io/JvXDl 63 | 64 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 65 | # and modify them (or add more) to build your code if your project 66 | # uses a compiled language 67 | 68 | # - run: | 69 | # make bootstrap 70 | # make release 71 | 72 | - name: Perform CodeQL Analysis 73 | uses: github/codeql-action/analyze@v3 74 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | ignore=tests 3 | # Use a conservative default here; 2 should speed up most setups and not hurt 4 | # any too bad. Override on command line as appropriate. 5 | jobs=2 6 | 7 | # Return non-zero exit code if any of these messages/categories are detected, 8 | # even if score is above --fail-under value. Syntax same as enable. Messages 9 | # specified are enabled, while categories only check already-enabled messages. 10 | fail-on= 11 | useless-suppression, 12 | 13 | # Specify a score threshold to be exceeded before program exits with error. 14 | fail-under=10.0 15 | 16 | # List of plugins (as comma separated values of python module names) to load, 17 | # usually to register additional checkers. 18 | # load-plugins= 19 | 20 | # Pickle collected data for later comparisons. 21 | persistent=no 22 | 23 | # A comma-separated list of package or module names from where C extensions may 24 | # be loaded. Extensions are loading into the active Python interpreter and may 25 | # run arbitrary code. (This is an alternative name to extension-pkg-allow-list 26 | # for backward compatibility.) 27 | extension-pkg-allow-list=ciso8601, 28 | cv2 29 | 30 | 31 | [BASIC] 32 | good-names=i,j,k,ex,_,T,x,y,id 33 | 34 | [MESSAGES CONTROL] 35 | # Reasons disabled: 36 | # format - handled by black 37 | # duplicate-code - unavoidable 38 | # cyclic-import - doesn't test if both import on load 39 | # too-many-* - are not enforced for the sake of readability 40 | # abstract-method - with intro of async there are always methods missing 41 | # inconsistent-return-statements - doesn't handle raise 42 | # wrong-import-order - isort guards this 43 | disable= 44 | format, 45 | abstract-method, 46 | cyclic-import, 47 | duplicate-code, 48 | inconsistent-return-statements, 49 | too-many-instance-attributes, 50 | wrong-import-order, 51 | too-few-public-methods 52 | 53 | # enable useless-suppression temporarily every now and then to clean them up 54 | enable= 55 | useless-suppression, 56 | use-symbolic-message-instead, 57 | 58 | [REPORTS] 59 | score=no 60 | 61 | [REFACTORING] 62 | 63 | # Maximum number of nested blocks for function / method body 64 | max-nested-blocks=5 65 | 66 | # Complete name of functions that never returns. When checking for 67 | # inconsistent-return-statements if a never returning function is called then 68 | # it will be considered as an explicit return statement and no message will be 69 | # printed. 70 | never-returning-functions=sys.exit,argparse.parse_error 71 | 72 | [FORMAT] 73 | expected-line-ending-format=LF 74 | 75 | [EXCEPTIONS] 76 | 77 | # Exceptions that will emit a warning when being caught. Defaults to 78 | # "BaseException, Exception". 79 | overgeneral-exceptions=builtins.BaseException, 80 | builtins.Exception 81 | 82 | [DESIGN] 83 | max-returns=10 84 | -------------------------------------------------------------------------------- /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "customizations": { 3 | "vscode": { 4 | "extensions": [ 5 | "esbenp.prettier-vscode", 6 | "github.vscode-pull-request-github", 7 | "ms-python.python", 8 | "ms-python.vscode-pylance", 9 | "ms-vscode.makefile-tools", 10 | "ryanluker.vscode-coverage-gutters", 11 | "visualstudioexptteam.vscodeintellicode" 12 | ], 13 | "settings": { 14 | "editor.formatOnPaste": false, 15 | "editor.formatOnSave": true, 16 | "editor.formatOnType": true, 17 | "editor.tabSize": 4, 18 | "files.eol": "\n", 19 | "files.trimTrailingWhitespace": true, 20 | "python.analysis.autoImportCompletions": true, 21 | "python.analysis.autoSearchPaths": false, 22 | "python.analysis.extraPaths": ["/home/vscode/.local/lib/python*/"], 23 | "python.analysis.typeCheckingMode": "basic", 24 | "python.defaultInterpreterPath": "/usr/local/bin/python", 25 | "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", 26 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 27 | "python.formatting.provider": "black", 28 | "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", 29 | "python.languageServer": "Pylance", 30 | "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", 31 | "python.linting.enabled": true, 32 | "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", 33 | "python.linting.mypyEnabled": true, 34 | "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", 35 | "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", 36 | "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", 37 | "python.linting.pylintArgs": ["--disable", "import-error"], 38 | "python.linting.pylintEnabled": true, 39 | "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint", 40 | "python.pythonPath": "/usr/local/python/bin/python", 41 | "python.testing.pytestEnabled": true, 42 | "python.testing.unittestEnabled": false, 43 | "terminal.integrated.defaultProfile.linux": "zsh", 44 | "terminal.integrated.profiles.linux": { 45 | "zsh": { 46 | "path": "/usr/bin/zsh" 47 | } 48 | } 49 | } 50 | } 51 | }, 52 | "features": { 53 | "rust": "latest" 54 | }, 55 | "forwardPorts": [8123], 56 | "image": "mcr.microsoft.com/devcontainers/python:3.13-bullseye", 57 | "name": "Bodymiscale", 58 | "portsAttributes": { 59 | "0-8122": { 60 | "label": "Auto-Forwarded - Other", 61 | "onAutoForward": "ignore" 62 | }, 63 | "8123": { 64 | "label": "Home Assistant" 65 | }, 66 | "8124-999999": { 67 | "label": "Auto-Forwarded - Other", 68 | "onAutoForward": "ignore" 69 | } 70 | }, 71 | "postCreateCommand": "scripts/setup", 72 | "remoteUser": "vscode", 73 | "runArgs": ["-e", "GIT_EDITOR=code --wait"] 74 | } 75 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/metrics/weight.py: -------------------------------------------------------------------------------- 1 | """Metrics module, which requires only weight.""" 2 | 3 | from collections.abc import Mapping 4 | from datetime import datetime 5 | from typing import Any 6 | 7 | from homeassistant.helpers.typing import StateType 8 | 9 | from custom_components.bodymiscale.const import CONF_GENDER, CONF_HEIGHT 10 | 11 | from ..models import Gender, Metric 12 | from ..util import check_value_constraints, to_float 13 | 14 | 15 | def get_bmi( 16 | config: Mapping[str, Any], metrics: Mapping[Metric, StateType | datetime] 17 | ) -> float: 18 | """Calculate BMI.""" 19 | height_val = to_float(config.get(CONF_HEIGHT)) 20 | weight = to_float(metrics.get(Metric.WEIGHT)) 21 | 22 | bmi = 0.0 23 | 24 | if height_val is not None and weight is not None: 25 | height_c = height_val / 100 26 | bmi = weight / (height_c * height_c) 27 | 28 | return check_value_constraints(bmi, 10, 90) 29 | 30 | 31 | def get_bmr( 32 | config: Mapping[str, Any], metrics: Mapping[Metric, StateType | datetime] 33 | ) -> float: 34 | """Calculate Basal Metabolic Rate (BMR).""" 35 | weight = to_float(metrics.get(Metric.WEIGHT)) 36 | age = to_float(metrics.get(Metric.AGE)) 37 | height = to_float(config.get(CONF_HEIGHT)) 38 | gender = config.get(CONF_GENDER) 39 | 40 | if weight is None or age is None or height is None or gender is None: 41 | return 0.0 42 | 43 | if gender == Gender.FEMALE: 44 | bmr = 864.6 + weight * 10.2036 45 | bmr -= height * 0.39336 46 | bmr -= age * 6.204 47 | else: 48 | bmr = 877.8 + weight * 14.916 49 | bmr -= height * 0.726 50 | bmr -= age * 8.976 51 | 52 | bmr = min(bmr, 5000) 53 | return check_value_constraints(bmr, 500, 5000) 54 | 55 | 56 | def get_visceral_fat( 57 | config: Mapping[str, Any], metrics: Mapping[Metric, StateType | datetime] 58 | ) -> float: 59 | """Calculate Visceral Fat.""" 60 | height = to_float(config.get(CONF_HEIGHT)) 61 | weight = to_float(metrics.get(Metric.WEIGHT)) 62 | age = to_float(metrics.get(Metric.AGE)) 63 | gender = config.get(CONF_GENDER) 64 | 65 | if height is None or weight is None or age is None or gender is None: 66 | return 1.0 67 | 68 | if gender == Gender.MALE: 69 | if height < weight * 1.6 + 63.0: 70 | vfal = age * 0.15 + ( 71 | (weight * 305.0) / ((height * 0.0826 * height - height * 0.4) + 48.0) 72 | - 2.9 73 | ) 74 | else: 75 | vfal = ( 76 | age * 0.15 77 | + (weight * (height * -0.0015 + 0.765) - height * 0.143) 78 | - 5.0 79 | ) 80 | else: 81 | if weight <= height * 0.5 - 13.0: 82 | vfal = ( 83 | age * 0.07 84 | + (weight * (height * -0.0024 + 0.691) - height * 0.027) 85 | - 10.5 86 | ) 87 | else: 88 | vfal = age * 0.07 + ( 89 | (weight * 500.0) / ((height * 1.45 + height * 0.1158 * height) - 120.0) 90 | - 6.0 91 | ) 92 | 93 | return check_value_constraints(vfal, 1, 50) 94 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/metrics/scale.py: -------------------------------------------------------------------------------- 1 | """Body scale module.""" 2 | 3 | from functools import cached_property 4 | 5 | from ..models import Gender 6 | 7 | 8 | class Scale: 9 | """Scale implementation.""" 10 | 11 | def __init__(self, height: int, gender: Gender): 12 | self._height = height 13 | self._gender = gender 14 | 15 | def get_fat_percentage(self, age: int) -> list[float]: 16 | """Get fat percentage.""" 17 | 18 | # The included tables where quite strange, maybe bogus, replaced them with better ones... 19 | scales: list[dict] = [ 20 | { 21 | "min": 0, 22 | "max": 12, 23 | Gender.FEMALE: [12.0, 21.0, 30.0, 34.0], 24 | Gender.MALE: [7.0, 16.0, 25.0, 30.0], 25 | }, 26 | { 27 | "min": 12, 28 | "max": 14, 29 | Gender.FEMALE: [15.0, 24.0, 33.0, 37.0], 30 | Gender.MALE: [7.0, 16.0, 25.0, 30.0], 31 | }, 32 | { 33 | "min": 14, 34 | "max": 16, 35 | Gender.FEMALE: [18.0, 27.0, 36.0, 40.0], 36 | Gender.MALE: [7.0, 16.0, 25.0, 30.0], 37 | }, 38 | { 39 | "min": 16, 40 | "max": 18, 41 | Gender.FEMALE: [20.0, 28.0, 37.0, 41.0], 42 | Gender.MALE: [7.0, 16.0, 25.0, 30.0], 43 | }, 44 | { 45 | "min": 18, 46 | "max": 40, 47 | Gender.FEMALE: [21.0, 28.0, 35.0, 40.0], 48 | Gender.MALE: [11.0, 17.0, 22.0, 27.0], 49 | }, 50 | { 51 | "min": 40, 52 | "max": 60, 53 | Gender.FEMALE: [22.0, 29.0, 36.0, 41.0], 54 | Gender.MALE: [12.0, 18.0, 23.0, 28.0], 55 | }, 56 | { 57 | "min": 60, 58 | "max": 100, 59 | Gender.FEMALE: [23.0, 30.0, 37.0, 42.0], 60 | Gender.MALE: [14.0, 20.0, 25.0, 30.0], 61 | }, 62 | ] 63 | 64 | for scale in scales: 65 | if scale["min"] <= age < scale["max"]: 66 | return scale[self._gender] # type: ignore 67 | 68 | # will never happen but mypy required it 69 | raise NotImplementedError 70 | 71 | @cached_property 72 | def muscle_mass(self) -> list[float]: 73 | """Get muscle mass.""" 74 | scales: list[dict] = [ 75 | { 76 | "min": {Gender.MALE: 170, Gender.FEMALE: 160}, 77 | Gender.FEMALE: [36.5, 42.6], 78 | Gender.MALE: [49.4, 59.5], 79 | }, 80 | { 81 | "min": {Gender.MALE: 160, Gender.FEMALE: 150}, 82 | Gender.FEMALE: [32.9, 37.6], 83 | Gender.MALE: [44.0, 52.5], 84 | }, 85 | { 86 | "min": {Gender.MALE: 0, Gender.FEMALE: 0}, 87 | Gender.FEMALE: [29.1, 34.8], 88 | Gender.MALE: [38.5, 46.6], 89 | }, 90 | ] 91 | 92 | for scale in scales: 93 | if self._height >= scale["min"][self._gender]: 94 | return scale[self._gender] # type: ignore 95 | 96 | # will never happen but mypy required it 97 | raise NotImplementedError 98 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/translations/zh-Hans.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "already_configured": "已配置", 5 | "height_limit": "身高过高(限制:220 厘米)", 6 | "height_low": "身高太低(最低:50 厘米)", 7 | "invalid_date": "无效日期" 8 | }, 9 | "step": { 10 | "options": { 11 | "data": { 12 | "height": "身高", 13 | "impedance": "阻抗传感器", 14 | "last_measurement_time": "上次称重时间传感器", 15 | "weight": "体重传感器" 16 | }, 17 | "data_description": { 18 | "impedance": "如果您的体秤不提供阻抗值,请将此字段留空。", 19 | "last_measurement_time": "如果您没有上次称重时间传感器,请将此字段留空。" 20 | } 21 | }, 22 | "user": { 23 | "data": { 24 | "birthday": "生日", 25 | "gender": "性别", 26 | "name": "姓名" 27 | } 28 | } 29 | } 30 | }, 31 | "entity": { 32 | "sensor": { 33 | "basal_metabolism": { "name": "基础代谢" }, 34 | "bmi": { "name": "BMI指数" }, 35 | "body_fat": { "name": "体脂率" }, 36 | "body_score": { "name": "体质评分" }, 37 | "bone_mass": { "name": "骨量" }, 38 | "last_measurement_time": { "name": "上次称重时间" }, 39 | "lean_body_mass": { "name": "瘦体重" }, 40 | "metabolic_age": { "name": "代谢年龄" }, 41 | "muscle_mass": { "name": "肌肉量" }, 42 | "protein": { "name": "蛋白质" }, 43 | "visceral_fat": { "name": "内脏脂肪" }, 44 | "water": { "name": "水分" }, 45 | "weight": { "name": "体重" } 46 | } 47 | }, 48 | "entity_component": { 49 | "_": { 50 | "name": "小米体脂秤", 51 | "state": { 52 | "ok": "正常", 53 | "problem": "问题" 54 | }, 55 | "state_attributes": { 56 | "age": { "name": "年龄" }, 57 | "basal_metabolism": { "name": "基础代谢" }, 58 | "bmi": { "name": "BMI指数" }, 59 | "bmi_label": { 60 | "name": "BMI 分类", 61 | "state": { 62 | "massive_obesity": "极重度肥胖", 63 | "moderate_obesity": "中度肥胖", 64 | "normal_or_healthy_weight": "正常或健康体重", 65 | "overweight": "超重", 66 | "severe_obesity": "重度肥胖", 67 | "slight_overweight": "轻度超重", 68 | "unavailable": "不可用", 69 | "underweight": "体重不足" 70 | } 71 | }, 72 | "body_fat": { "name": "体脂率" }, 73 | "body_score": { "name": "体质评分" }, 74 | "body_type": { 75 | "name": "体型", 76 | "state": { 77 | "balanced": "均衡", 78 | "balanced_muscular": "均衡-肌肉型", 79 | "balanced_skinny": "均衡-瘦型", 80 | "lack_exercise": "缺乏锻炼", 81 | "obese": "肥胖", 82 | "overweight": "超重", 83 | "skinny": "瘦型", 84 | "skinny_muscular": "瘦型-肌肉型", 85 | "thick_set": "丰满", 86 | "unavailable": "不可用" 87 | } 88 | }, 89 | "bone_mass": { "name": "骨量" }, 90 | "fat_mass_to_gain": { "name": "获得脂肪质量" }, 91 | "fat_mass_to_lose": { "name": "减少脂肪质量" }, 92 | "gender": { 93 | "name": "性别", 94 | "state": { 95 | "female": "女性", 96 | "male": "男性" 97 | } 98 | }, 99 | "height": { "name": "身高" }, 100 | "ideal": { "name": "理想" }, 101 | "impedance": { "name": "阻抗" }, 102 | "last_measurement_time": { "name": "上次称重时间" }, 103 | "lean_body_mass": { "name": "瘦体重" }, 104 | "metabolic_age": { "name": "代谢年龄" }, 105 | "muscle_mass": { "name": "肌肉量" }, 106 | "problem": { 107 | "name": "问题", 108 | "state": { 109 | "impedance_high": "阻抗高", 110 | "impedance_low": "阻抗低", 111 | "impedance_unavailable": "阻抗不可用", 112 | "none": "无", 113 | "weight_high": "体重高", 114 | "weight_high_and_impedance_high": "体重高,阻抗高", 115 | "weight_high_and_impedance_low": "体重高,阻抗低", 116 | "weight_low": "体重低", 117 | "weight_low_and_impedance_high": "体重低,阻抗高", 118 | "weight_low_and_impedance_low": "体重低,阻抗低", 119 | "weight_unavailable": "体重不可用", 120 | "weight_unavailable_and_impedance_unavailable": "体重不可用,阻抗不可用" 121 | } 122 | }, 123 | "protein": { "name": "蛋白质" }, 124 | "visceral_fat": { "name": "内脏脂肪" }, 125 | "water": { "name": "水分" }, 126 | "weight": { "name": "体重" } 127 | } 128 | } 129 | }, 130 | "options": { 131 | "step": { 132 | "init": { 133 | "data": { 134 | "height": "身高", 135 | "impedance": "阻抗传感器", 136 | "last_measurement_time": "上次称重时间传感器", 137 | "weight": "体重传感器" 138 | }, 139 | "data_description": { 140 | "impedance": "如果您的体秤不提供阻抗值,请将此字段留空。", 141 | "last_measurement_time": "如果您没有上次称重时间传感器,请将此字段留空。" 142 | } 143 | } 144 | } 145 | }, 146 | "title": "小米体脂秤" 147 | } 148 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/translations/zh-Hant.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "already_configured": "已配置", 5 | "height_limit": "身高過高(限制:220 釐米)", 6 | "height_low": "身高太低(最低:50 釐米)", 7 | "invalid_date": "無效日期" 8 | }, 9 | "step": { 10 | "options": { 11 | "data": { 12 | "height": "身高", 13 | "impedance": "阻抗傳感器", 14 | "last_measurement_time": "上次稱重時間感測器", 15 | "weight": "體重傳感器" 16 | }, 17 | "data_description": { 18 | "impedance": "如果您的體計不提供阻抗值,請將此欄位留空。", 19 | "last_measurement_time": "如果您沒有上次稱重時間感測器,請將此欄位留白。" 20 | } 21 | }, 22 | "user": { 23 | "data": { 24 | "birthday": "生日", 25 | "gender": "性別", 26 | "name": "姓名" 27 | } 28 | } 29 | } 30 | }, 31 | "entity": { 32 | "sensor": { 33 | "basal_metabolism": { "name": "基礎代謝" }, 34 | "bmi": { "name": "BMI指數" }, 35 | "body_fat": { "name": "體脂率" }, 36 | "body_score": { "name": "體質評分" }, 37 | "bone_mass": { "name": "骨量" }, 38 | "last_measurement_time": { "name": "上次稱重時間" }, 39 | "lean_body_mass": { "name": "瘦體重" }, 40 | "metabolic_age": { "name": "代謝年齡" }, 41 | "muscle_mass": { "name": "肌肉量" }, 42 | "protein": { "name": "蛋白質" }, 43 | "visceral_fat": { "name": "內臟脂肪" }, 44 | "water": { "name": "水分" }, 45 | "weight": { "name": "體重" } 46 | } 47 | }, 48 | "entity_component": { 49 | "_": { 50 | "name": "小米體脂計", 51 | "state": { 52 | "ok": "正常", 53 | "problem": "問題" 54 | }, 55 | "state_attributes": { 56 | "age": { "name": "年齡" }, 57 | "basal_metabolism": { "name": "基礎代謝" }, 58 | "bmi": { "name": "BMI指數" }, 59 | "bmi_label": { 60 | "name": "BMI 分類", 61 | "state": { 62 | "massive_obesity": "極重度肥胖", 63 | "moderate_obesity": "中度肥胖", 64 | "normal_or_healthy_weight": "正常或健康體重", 65 | "overweight": "超重", 66 | "severe_obesity": "重度肥胖", 67 | "slight_overweight": "輕度超重", 68 | "unavailable": "不可用", 69 | "underweight": "體重不足" 70 | } 71 | }, 72 | "body_fat": { "name": "體脂率" }, 73 | "body_score": { "name": "體質評分" }, 74 | "body_type": { 75 | "name": "體型", 76 | "state": { 77 | "balanced": "均衡", 78 | "balanced_muscular": "均衡-肌肉型", 79 | "balanced_skinny": "均衡-瘦型", 80 | "lack_exercise": "缺乏鍛鍊", 81 | "obese": "肥胖", 82 | "overweight": "超重", 83 | "skinny": "瘦型", 84 | "skinny_muscular": "瘦型-肌肉型", 85 | "thick_set": "豐滿", 86 | "unavailable": "不可用" 87 | } 88 | }, 89 | "bone_mass": { "name": "骨量" }, 90 | "fat_mass_to_gain": { "name": "獲得脂肪質量" }, 91 | "fat_mass_to_lose": { "name": "減少脂肪質量" }, 92 | "gender": { 93 | "name": "性別", 94 | "state": { 95 | "female": "女性", 96 | "male": "男性" 97 | } 98 | }, 99 | "height": { "name": "身高" }, 100 | "ideal": { "name": "理想" }, 101 | "impedance": { "name": "阻抗" }, 102 | "last_measurement_time": { "name": "上次稱重時間" }, 103 | "lean_body_mass": { "name": "瘦體重" }, 104 | "metabolic_age": { "name": "代謝年齡" }, 105 | "muscle_mass": { "name": "肌肉量" }, 106 | "problem": { 107 | "name": "問題", 108 | "state": { 109 | "impedance_high": "阻抗高", 110 | "impedance_low": "阻抗低", 111 | "impedance_unavailable": "阻抗不可用", 112 | "none": "無", 113 | "weight_high": "體重高", 114 | "weight_high_and_impedance_high": "體重高,阻抗高", 115 | "weight_high_and_impedance_low": "體重高,阻抗低", 116 | "weight_low": "體重低", 117 | "weight_low_and_impedance_high": "體重低,阻抗高", 118 | "weight_low_and_impedance_low": "體重低,阻抗低", 119 | "weight_unavailable": "體重不可用", 120 | "weight_unavailable_and_impedance_unavailable": "體重不可用,阻抗不可用" 121 | } 122 | }, 123 | "protein": { "name": "蛋白質" }, 124 | "visceral_fat": { "name": "內臟脂肪" }, 125 | "water": { "name": "水分" }, 126 | "weight": { "name": "體重" } 127 | } 128 | } 129 | }, 130 | "options": { 131 | "step": { 132 | "init": { 133 | "data": { 134 | "height": "身高", 135 | "impedance": "阻抗傳感器", 136 | "last_measurement_time": "上次稱重時間感測器", 137 | "weight": "體重傳感器" 138 | }, 139 | "data_description": { 140 | "impedance": "如果您的體秤不提供阻抗值,請將此欄位留空。", 141 | "last_measurement_time": "如果您沒有上次稱重時間感測器,請將此欄位留白。" 142 | } 143 | } 144 | } 145 | }, 146 | "title": "小米體脂計" 147 | } 148 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "already_configured": "Bereits konfiguriert", 5 | "height_limit": "Die Höhe ist zu hoch (Grenze: 220 cm)", 6 | "height_low": "Die Höhe ist zu niedrig (min : 50 cm)", 7 | "invalid_date": "Ungültiges Datum" 8 | }, 9 | "step": { 10 | "options": { 11 | "data": { 12 | "height": "Größe", 13 | "impedance": "Impedanz sensor", 14 | "last_measurement_time": "Sensor der letzten Wägezeit", 15 | "weight": "Gewicht sensor" 16 | }, 17 | "data_description": { 18 | "impedance": "Falls deine Waage keine Impedanz liefert, lasse dieses Feld leer.", 19 | "last_measurement_time": "Lassen Sie dieses Feld leer, wenn Sie keinen Sensor für die letzte Wägezeit haben." 20 | } 21 | }, 22 | "user": { 23 | "data": { 24 | "birthday": "Geburtstag", 25 | "gender": "Geschlecht", 26 | "name": "Name" 27 | } 28 | } 29 | } 30 | }, 31 | "entity": { 32 | "sensor": { 33 | "basal_metabolism": { "name": "Grundumsatz" }, 34 | "bmi": { "name": "BMI" }, 35 | "body_fat": { "name": "Körperfett" }, 36 | "body_score": { "name": "Körperbewertung" }, 37 | "bone_mass": { "name": "Knochenmasse" }, 38 | "last_measurement_time": { "name": "Letzte Wägezeit" }, 39 | "lean_body_mass": { "name": "Magere Körpermasse" }, 40 | "metabolic_age": { "name": "stoffwechselbedingtes Körperalter" }, 41 | "muscle_mass": { "name": "Muskelmasse" }, 42 | "protein": { "name": "Protein" }, 43 | "visceral_fat": { "name": "Bauchfett" }, 44 | "water": { "name": "Wasser" }, 45 | "weight": { "name": "Gewicht" } 46 | } 47 | }, 48 | "entity_component": { 49 | "_": { 50 | "name": "bodymiscale", 51 | "state": { 52 | "ok": "OK", 53 | "problem": "Problem" 54 | }, 55 | "state_attributes": { 56 | "age": { "name": "Alter" }, 57 | "basal_metabolism": { "name": "Grundumsatz" }, 58 | "bmi": { "name": "BMI" }, 59 | "bmi_label": { 60 | "name": "BMI Klassifikation", 61 | "state": { 62 | "massive_obesity": "massive Fettleibigkeit", 63 | "moderate_obesity": "moderate Fettleibigkeit", 64 | "normal_or_healthy_weight": "Normal - gesundes Gewicht", 65 | "overweight": "Übergewicht", 66 | "severe_obesity": "schwere Fettleibigkeit", 67 | "slight_overweight": "leichtes Übergewicht", 68 | "unavailable": "Nicht verfügbar", 69 | "underweight": "Untergewicht" 70 | } 71 | }, 72 | "body_fat": { "name": "Körperfett" }, 73 | "body_score": { "name": "Körperbewertung" }, 74 | "body_type": { 75 | "name": "Körperbau", 76 | "state": { 77 | "balanced": "ausgewogen", 78 | "balanced_muscular": "ausgewogen muskulös", 79 | "balanced_skinny": "ausgeglichen schlank", 80 | "lack_exercise": "Bewegungsmangel", 81 | "obese": "fettleibig", 82 | "overweight": "Übergewicht", 83 | "skinny": "schlank", 84 | "skinny_muscular": "muskulös schlank", 85 | "thick_set": "stämmig", 86 | "unavailable": "Nicht verfügbar" 87 | } 88 | }, 89 | "bone_mass": { "name": "Knochenmasse" }, 90 | "fat_mass_to_gain": { "name": "Fettmasse zu gewinnen" }, 91 | "fat_mass_to_lose": { "name": "Fettmasse zu verlieren" }, 92 | "gender": { 93 | "name": "Geschlecht", 94 | "state": { 95 | "female": "weibl.", 96 | "male": "männl." 97 | } 98 | }, 99 | "height": { "name": "Körpergröße" }, 100 | "ideal": { "name": "Idealgewicht" }, 101 | "impedance": { "name": "Zusammensetzung" }, 102 | "last_measurement_time": { "name": "Letzte Wägezeit" }, 103 | "lean_body_mass": { "name": "Magere Körpermasse" }, 104 | "metabolic_age": { "name": "stoffwechselbedingtes Körperalter" }, 105 | "muscle_mass": { "name": "Muskelmasse" }, 106 | "problem": { 107 | "name": "Problem", 108 | "state": { 109 | "impedance_unavailable": "Bioelektrische Impedanzmessung (Körperzusammensetzung) nicht verfügbar", 110 | "none": "keine", 111 | "weight unavailable_and_impedance unavailable": "Gewichts- und bioelektrische Impedanzmessung (Körperzusammensetzung) nicht verfügbar.", 112 | "weight_unavailable": "Gewichtsmessung nicht verfügbar" 113 | } 114 | }, 115 | "protein": { "name": "Protein" }, 116 | "visceral_fat": { "name": "Bauchfett" }, 117 | "water": { "name": "Wasser" }, 118 | "weight": { "name": "Gewicht" } 119 | } 120 | } 121 | }, 122 | "options": { 123 | "step": { 124 | "init": { 125 | "data": { 126 | "height": "Größe", 127 | "impedance": "Impedanz sensor", 128 | "last_measurement_time": "Sensor der letzten Wägezeit", 129 | "weight": "Gewicht sensor" 130 | }, 131 | "data_description": { 132 | "impedance": "Falls deine Waage keine Impedanz liefert, lasse dieses Feld leer.", 133 | "last_measurement_time": "Lassen Sie dieses Feld leer, wenn Sie keinen Sensor für die letzte Wägezeit haben." 134 | } 135 | } 136 | } 137 | }, 138 | "title": "BodyMiScale" 139 | } 140 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "already_configured": "Already configured", 5 | "height_limit": "Height is too high (limit: 220 cm)", 6 | "height_low": "Height is too low (min : 50 cm)", 7 | "invalid_date": "Invalid date" 8 | }, 9 | "step": { 10 | "options": { 11 | "data": { 12 | "height": "Height", 13 | "impedance": "Impedance sensor", 14 | "last_measurement_time": "Last measurement time sensor", 15 | "weight": "Weight sensor" 16 | }, 17 | "data_description": { 18 | "impedance": "If your scale does not provide the impedance, leave this field empty.", 19 | "last_measurement_time": "If you do not have a last weigh time sensor, leave this field blank." 20 | } 21 | }, 22 | "user": { 23 | "data": { 24 | "birthday": "Birthday", 25 | "gender": "Gender", 26 | "name": "Name" 27 | } 28 | } 29 | } 30 | }, 31 | "entity": { 32 | "sensor": { 33 | "basal_metabolism": { "name": "Basal metabolism" }, 34 | "bmi": { "name": "BMI" }, 35 | "body_fat": { "name": "Body fat" }, 36 | "body_score": { "name": "Body score" }, 37 | "bone_mass": { "name": "Bone mass" }, 38 | "last_measurement_time": { "name": "Last measurement time" }, 39 | "lean_body_mass": { "name": "Lean body mass" }, 40 | "metabolic_age": { "name": "Metabolic age" }, 41 | "muscle_mass": { "name": "Muscle mass" }, 42 | "protein": { "name": "Protein" }, 43 | "visceral_fat": { "name": "Visceral fat" }, 44 | "water": { "name": "Water" }, 45 | "weight": { "name": "Weight" } 46 | } 47 | }, 48 | "entity_component": { 49 | "_": { 50 | "name": "bodymiscale", 51 | "state": { 52 | "ok": "OK", 53 | "problem": "Problem" 54 | }, 55 | "state_attributes": { 56 | "age": { "name": "Age" }, 57 | "basal_metabolism": { "name": "Basal metabolism" }, 58 | "bmi": { "name": "BMI" }, 59 | "bmi_label": { 60 | "name": "BMI label", 61 | "state": { 62 | "massive_obesity": "Massive obesity", 63 | "moderate_obesity": "Moderate obesity", 64 | "normal_or_healthy_weight": "Normal or Healthy Weight", 65 | "overweight": "Overweight", 66 | "severe_obesity": "Severe obesity", 67 | "slight_overweight": "Slight overweight", 68 | "unavailable": "unavailable", 69 | "underweight": "Underweight" 70 | } 71 | }, 72 | "body_fat": { "name": "Body fat" }, 73 | "body_score": { "name": "Body score" }, 74 | "body_type": { 75 | "name": "Body type", 76 | "state": { 77 | "balanced": "Balanced", 78 | "balanced_muscular": "Balanced-muscular", 79 | "balanced_skinny": "Balanced-skinny", 80 | "lack_exercise": "Lack-exercise", 81 | "obese": "Obese", 82 | "overweight": "Overweight", 83 | "skinny": "Skinny", 84 | "skinny_muscular": "Skinny-muscular", 85 | "thick_set": "Thick-set", 86 | "unavailable": "unavailable" 87 | } 88 | }, 89 | "bone_mass": { "name": "Bone mass" }, 90 | "fat_mass_to_gain": { "name": "Fat mass to gain" }, 91 | "fat_mass_to_lose": { "name": "Fat mass to lose" }, 92 | "gender": { 93 | "name": "Gender", 94 | "state": { 95 | "female": "female", 96 | "male": "male" 97 | } 98 | }, 99 | "height": { "name": "Height" }, 100 | "ideal": { "name": "Ideal" }, 101 | "impedance": { "name": "Impedance" }, 102 | "last_measurement_time": { "name": "Last measurement time" }, 103 | "lean_body_mass": { "name": "Lean body mass" }, 104 | "metabolic_age": { "name": "Metabolic age" }, 105 | "muscle_mass": { "name": "Muscle mass" }, 106 | "problem": { 107 | "name": "Problem", 108 | "state": { 109 | "impedance_high": "Impedance high", 110 | "impedance_low": "Impedance low", 111 | "impedance_unavailable": "Impedance unavailable", 112 | "none": "None", 113 | "weight_high": "Weight high", 114 | "weight_high_and_impedance_high": "Weight high, impedance high", 115 | "weight_high_and_impedance_low": "Weight high, impedance low", 116 | "weight_low": "Weight low", 117 | "weight_low_and_impedance_high": "Weight low, impedance high", 118 | "weight_low_and_impedance_low": "Weight low, impedance low", 119 | "weight_unavailable": "Weight unavailable", 120 | "weight_unavailable_and_impedance_unavailable": "Weight unavailable, impedance unavailable" 121 | } 122 | }, 123 | "protein": { "name": "Protein" }, 124 | "visceral_fat": { "name": "Visceral fat" }, 125 | "water": { "name": "Water" }, 126 | "weight": { "name": "Weight" } 127 | } 128 | } 129 | }, 130 | "options": { 131 | "step": { 132 | "init": { 133 | "data": { 134 | "height": "Height", 135 | "impedance": "Impedance sensor", 136 | "last_measurement_time": "Last measurement time sensor", 137 | "weight": "Weight sensor" 138 | }, 139 | "data_description": { 140 | "impedance": "If your scale does not provide the impedance, leave this field empty.", 141 | "last_measurement_time": "If you do not have a last weigh time sensor, leave this field blank." 142 | } 143 | } 144 | } 145 | }, 146 | "title": "BodyMiScale" 147 | } 148 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/translations/da.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "already_configured": "Allerede konfigureret", 5 | "height_limit": "Højde maximum overskredet (grænse: 220 cm)", 6 | "height_low": "Højde minimum overskredet (min : 50 cm)", 7 | "invalid_date": "Ugyldig dato" 8 | }, 9 | "step": { 10 | "options": { 11 | "data": { 12 | "height": "Højde", 13 | "impedance": "Impedanssensor", 14 | "last_measurement_time": "Tidssensor for sidste måling", 15 | "weight": "Vægtsensor" 16 | }, 17 | "data_description": { 18 | "impedance": "Har din vægt ikke impedanssensor, udfyld ikke dette felt.", 19 | "last_measurement_time": "udfyld ikke dette felt hvis der ikke er en tidssensor for sidste måling." 20 | } 21 | }, 22 | "user": { 23 | "data": { 24 | "birthday": "Fødselsdag", 25 | "gender": "Køn", 26 | "name": "Navn" 27 | } 28 | } 29 | } 30 | }, 31 | "entity": { 32 | "sensor": { 33 | "basal_metabolism": { "name": "Basal stofskifte" }, 34 | "bmi": { "name": "BMI" }, 35 | "body_fat": { "name": "Kropsfedt" }, 36 | "body_score": { "name": "Kropsscore" }, 37 | "bone_mass": { "name": "Knoglemasse" }, 38 | "last_measurement_time": { "name": "Tidspunkt for sidste måling" }, 39 | "lean_body_mass": { "name": "Mager kropsmasse" }, 40 | "metabolic_age": { "name": "Metabolisk alder" }, 41 | "muscle_mass": { "name": "Muskelmasse" }, 42 | "protein": { "name": "Protein" }, 43 | "visceral_fat": { "name": "Visceralt fedt" }, 44 | "water": { "name": "Vand" }, 45 | "weight": { "name": "Vægt" } 46 | } 47 | }, 48 | "entity_component": { 49 | "_": { 50 | "name": "bodymiscale", 51 | "state": { 52 | "ok": "OK", 53 | "problem": "Problem" 54 | }, 55 | "state_attributes": { 56 | "age": { "name": "Alder" }, 57 | "basal_metabolism": { "name": "Basal stofskifte" }, 58 | "bmi": { "name": "BMI" }, 59 | "bmi_label": { 60 | "name": "BMI klasse", 61 | "state": { 62 | "massive_obesity": "Massiv fedme", 63 | "moderate_obesity": "Moderat fedme", 64 | "normal_or_healthy_weight": "Normal eller sund vægt", 65 | "overweight": "Overvægtig", 66 | "severe_obesity": "Alvorlig fedme", 67 | "slight_overweight": "Let overvægt", 68 | "unavailable": "ikke tilgængelig", 69 | "underweight": "Undervægtig" 70 | } 71 | }, 72 | "body_fat": { "name": "Kropsfedt" }, 73 | "body_score": { "name": "Kropsvurdering" }, 74 | "body_type": { 75 | "name": "Kropstype", 76 | "state": { 77 | "balanced": "Balanceret", 78 | "balanced_muscular": "Balanceret muskuløs", 79 | "balanced_skinny": "Balanceret slank", 80 | "lack_exercise": "Mangel på motion", 81 | "obese": "Fed", 82 | "overweight": "Overvægtig", 83 | "skinny": "Slank", 84 | "skinny_muscular": "Muskuløs slank", 85 | "thick_set": "Kraftig", 86 | "unavailable": "ikke tilgængelig" 87 | } 88 | }, 89 | "bone_mass": { "name": "Knoglemasse" }, 90 | "fat_mass_to_gain": { "name": "Fedtmasse at tage på" }, 91 | "fat_mass_to_lose": { "name": "Fedtmasse at tabe" }, 92 | "gender": { 93 | "name": "Navn", 94 | "state": { 95 | "female": "kvinde", 96 | "male": "mand" 97 | } 98 | }, 99 | "height": { "name": "Højde" }, 100 | "ideal": { "name": "Ideel" }, 101 | "impedance": { "name": "Impedans" }, 102 | "last_measurement_time": { "name": "Tidspunkt for sidste måling" }, 103 | "lean_body_mass": { "name": "Mager kropsmasse" }, 104 | "metabolic_age": { "name": "Metabolisk alder" }, 105 | "muscle_mass": { "name": "Muskelmasse" }, 106 | "problem": { 107 | "name": "Problem", 108 | "state": { 109 | "impedance_high": "Høj impedans", 110 | "impedance_low": "Lav impedans", 111 | "impedance_unavailable": "Impedans ikke tilgængelig", 112 | "none": "Ingen", 113 | "weight_high": "Høj vægt", 114 | "weight_high_and_impedance_high": "Høj vægt, høj impedans", 115 | "weight_high_and_impedance_low": "Høj vægt, lav impedans", 116 | "weight_low": "Weight low", 117 | "weight_low_and_impedance_high": "Lav vægt, høj impedans", 118 | "weight_low_and_impedance_low": "Lav vægt, lav impedans", 119 | "weight_unavailable": "Vægt ikke tilgængelig", 120 | "weight_unavailable_and_impedance_unavailable": "Vægt ikke tilgængelig, impedanse ikke tilgængelig" 121 | } 122 | }, 123 | "protein": { "name": "Protein" }, 124 | "visceral_fat": { "name": "Visceralt fedt" }, 125 | "water": { "name": "Vand" }, 126 | "weight": { "name": "Vægt" } 127 | } 128 | } 129 | }, 130 | "options": { 131 | "step": { 132 | "init": { 133 | "data": { 134 | "height": "Højde", 135 | "impedance": "Impedansesensor", 136 | "last_measurement_time": "Tidssensor for sidste måling", 137 | "weight": "Vægtsensor" 138 | }, 139 | "data_description": { 140 | "impedance": "Hvis din vægt ikke har impedans sensor, udfyld ikke dette felt.", 141 | "last_measurement_time": "udfyld ikke dette felt hvid der ikke er en tidssensor for sidste måling." 142 | } 143 | } 144 | } 145 | }, 146 | "title": "BodyMiScale" 147 | } 148 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/translations/sk.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "already_configured": "Už nakonfigurované", 5 | "height_limit": "Výška je príliš vysoká (limit: 220 cm)", 6 | "height_low": "Výška je príliš nízka (min: 50 cm)", 7 | "invalid_date": "Neplatný dátum" 8 | }, 9 | "step": { 10 | "options": { 11 | "data": { 12 | "height": "Výška", 13 | "impedance": "Senzor impedancie", 14 | "last_measurement_time": "Senzor posledného času váženia", 15 | "weight": "Senzor hmotnosti" 16 | }, 17 | "data_description": { 18 | "impedance": "Ak vaša váha neposkytuje impedanciu, nechajte toto pole prázdne.", 19 | "last_measurement_time": "Ak nemáte senzor posledného času váženia, nechajte toto pole prázdne." 20 | } 21 | }, 22 | "user": { 23 | "data": { 24 | "birthday": "Narodeniny", 25 | "gender": "Pohlavie", 26 | "name": "Meno" 27 | } 28 | } 29 | } 30 | }, 31 | "entity": { 32 | "sensor": { 33 | "basal_metabolism": { "name": "Bazálny metabolizmus" }, 34 | "bmi": { "name": "BMI" }, 35 | "body_fat": { "name": "Telesný tuk" }, 36 | "body_score": { "name": "Skóre tela" }, 37 | "bone_mass": { "name": "Kostná hmota" }, 38 | "last_measurement_time": { "name": "Posledný čas váženia" }, 39 | "lean_body_mass": { "name": "Čistá telesná hmota" }, 40 | "metabolic_age": { "name": "Metabolický vek" }, 41 | "muscle_mass": { "name": "Svalová hmota" }, 42 | "protein": { "name": "Proteín" }, 43 | "visceral_fat": { "name": "Viscerálny tuk" }, 44 | "water": { "name": "Voda" }, 45 | "weight": { "name": "Hmotnosť" } 46 | } 47 | }, 48 | "entity_component": { 49 | "_": { 50 | "name": "bodymiscale", 51 | "state": { 52 | "ok": "OK", 53 | "problem": "Problém" 54 | }, 55 | "state_attributes": { 56 | "age": { "name": "Vek" }, 57 | "basal_metabolism": { "name": "Bazálny metabolizmus" }, 58 | "bmi": { "name": "BMI" }, 59 | "bmi_label": { 60 | "name": "BMI označenie", 61 | "state": { 62 | "massive_obesity": "Masívna obezita", 63 | "moderate_obesity": "Mierna obezita", 64 | "normal_or_healthy_weight": "Normálna alebo zdravá hmotnosť", 65 | "overweight": "Nadváha", 66 | "severe_obesity": "Závažná obezita", 67 | "slight_overweight": "Mierna nadváha", 68 | "unavailable": "Nedostupné", 69 | "underweight": "Podváha" 70 | } 71 | }, 72 | "body_fat": { "name": "Telesný tuk" }, 73 | "body_score": { "name": "Skóre tela" }, 74 | "body_type": { 75 | "name": "Typ tela", 76 | "state": { 77 | "balanced": "Vyvážený", 78 | "balanced_muscular": "Vyvážený-svalnatý", 79 | "balanced_skinny": "Vyvážený-chudý", 80 | "lack_exercise": "Nedostatok-pohybu", 81 | "obese": "Obezita", 82 | "overweight": "Nadváha", 83 | "skinny": "Chudý", 84 | "skinny_muscular": "Chudý-svalnatý", 85 | "thick_set": "Hrubý", 86 | "unavailable": "Nedostupné" 87 | } 88 | }, 89 | "bone_mass": { "name": "Kostná hmota" }, 90 | "fat_mass_to_gain": { "name": "Tuková hmota na pribratie" }, 91 | "fat_mass_to_lose": { "name": "Tuková hmota na schudnutie" }, 92 | "gender": { 93 | "name": "Pohlavie", 94 | "state": { 95 | "female": "žena", 96 | "male": "muž" 97 | } 98 | }, 99 | "height": { "name": "Výška" }, 100 | "ideal": { "name": "Ideál" }, 101 | "impedance": { "name": "Impedancia" }, 102 | "last_measurement_time": { "name": "Posledný čas váženia" }, 103 | "lean_body_mass": { "name": "Čistá telesná hmota" }, 104 | "metabolic_age": { "name": "Metabolický vek" }, 105 | "muscle_mass": { "name": "Svalová hmota" }, 106 | "problem": { 107 | "name": "Problém", 108 | "state": { 109 | "impedance_high": "Vysoká impedancia", 110 | "impedance_low": "Nízka impedancia", 111 | "impedance_unavailable": "Impedancia nedostupná", 112 | "none": "Žiadny", 113 | "weight_high": "Vysoká hmotnosť", 114 | "weight_high_and_impedance_high": "Vysoká hmotnosť, vysoká impedancia", 115 | "weight_high_and_impedance_low": "Vysoká hmotnosť, nízka impedancia", 116 | "weight_low": "Nízka hmotnosť", 117 | "weight_low_and_impedance_high": "Nízka hmotnosť, vysoká impedancia", 118 | "weight_low_and_impedance_low": "Nízka hmotnosť, nízka impedancia", 119 | "weight_unavailable": "Hmotnosť nedostupná", 120 | "weight_unavailable_and_impedance_unavailable": "Hmotnosť nedostupná, impedancia nedostupná" 121 | } 122 | }, 123 | "protein": { "name": "Proteín" }, 124 | "visceral_fat": { "name": "Viscerálny tuk" }, 125 | "water": { "name": "Voda" }, 126 | "weight": { "name": "Hmotnosť" } 127 | } 128 | } 129 | }, 130 | "options": { 131 | "step": { 132 | "init": { 133 | "data": { 134 | "height": "Výška", 135 | "impedance": "Senzor impedancie", 136 | "last_measurement_time": "Senzor posledného času váženia", 137 | "weight": "Senzor hmotnosti" 138 | }, 139 | "data_description": { 140 | "impedance": "Ak vaša váha neposkytuje impedanciu, nechajte toto pole prázdne.", 141 | "last_measurement_time": "Ak nemáte senzor posledného času váženia, nechajte toto pole prázdne." 142 | } 143 | } 144 | } 145 | }, 146 | "title": "BodyMiScale" 147 | } 148 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/translations/pl.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "already_configured": "Już skonfigurowane", 5 | "height_limit": "Podano za wysoki wzrost (limit: 220 cm)", 6 | "height_low": "Wzrost uznano za zbyt niski (min : 50 cm)", 7 | "invalid_date": "Błędna data" 8 | }, 9 | "step": { 10 | "options": { 11 | "data": { 12 | "height": "Wzrost", 13 | "impedance": "Sensor impedancji", 14 | "last_measurement_time": "Czujnik ostatniej godziny ważenia", 15 | "weight": "Sensor wagi" 16 | }, 17 | "data_description": { 18 | "impedance": "Jeśli waga nie podaje impedancji, pozostaw to pole puste.", 19 | "last_measurement_time": "Jeśli nie masz czujnika ostatniej godziny ważenia, pozostaw to pole puste." 20 | } 21 | }, 22 | "user": { 23 | "data": { 24 | "birthday": "Urodziny", 25 | "gender": "Płeć", 26 | "name": "Imię" 27 | } 28 | } 29 | } 30 | }, 31 | "entity": { 32 | "sensor": { 33 | "basal_metabolism": { "name": "Podstawowa przemiana materii" }, 34 | "bmi": { "name": "BMI" }, 35 | "body_fat": { "name": "Tkanina tłuszczowa" }, 36 | "body_score": { "name": "Wynik ciała" }, 37 | "bone_mass": { "name": "Masa kości" }, 38 | "last_measurement_time": { "name": "Ostatnia godzina ważenia" }, 39 | "lean_body_mass": { "name": "Beztłuszczowa masa ciała" }, 40 | "metabolic_age": { "name": "Wiek metaboliczny" }, 41 | "muscle_mass": { "name": "Masa mięśniowa" }, 42 | "protein": { "name": "Białko" }, 43 | "visceral_fat": { "name": "Tłuszcz trzewny" }, 44 | "water": { "name": "Woda" }, 45 | "weight": { "name": "Waga" } 46 | } 47 | }, 48 | "entity_component": { 49 | "_": { 50 | "name": "bodymiscale", 51 | "state": { 52 | "ok": "OK", 53 | "problem": "Błąd" 54 | }, 55 | "state_attributes": { 56 | "age": { "name": "Wiek" }, 57 | "basal_metabolism": { "name": "Podstawowa przemiana materii" }, 58 | "bmi": { "name": "BMI" }, 59 | "bmi_label": { 60 | "name": "Etykieta BMI", 61 | "state": { 62 | "massive_obesity": "Ogromna otyłość", 63 | "moderate_obesity": "Umiarkowana otyłość", 64 | "normal_or_healthy_weight": "Normalna lub zdrowa waga", 65 | "overweight": "Nadwaga", 66 | "severe_obesity": "Ciężka otyłość", 67 | "slight_overweight": "Niewielka nadwaga", 68 | "unavailable": "niedostępny", 69 | "underweight": "Niedowaga" 70 | } 71 | }, 72 | "body_fat": { "name": "Tkanina tłuszczowa" }, 73 | "body_score": { "name": "Wynik ciała" }, 74 | "body_type": { 75 | "name": "Typ sylwetki", 76 | "state": { 77 | "balanced": "Zrównoważona", 78 | "balanced_muscular": "Zrównoważona muskularna", 79 | "balanced_skinny": "Zrównoważona chuda", 80 | "lack_exercise": "Brak ćwiczeń", 81 | "obese": "Otyła", 82 | "overweight": "Nadwaga", 83 | "skinny": "Chuda", 84 | "skinny_muscular": "Chuda muskularna", 85 | "thick_set": "Krępa", 86 | "unavailable": "niedostępny" 87 | } 88 | }, 89 | "bone_mass": { "name": "Masa kości" }, 90 | "fat_mass_to_gain": { "name": "Masa tłuszczowa do zdobycia" }, 91 | "fat_mass_to_lose": { "name": "Masa tłuszczowa do stracenia" }, 92 | "gender": { 93 | "name": "Płeć", 94 | "state": { 95 | "female": "żeńska", 96 | "male": "męska" 97 | } 98 | }, 99 | "height": { "name": "Wzrost" }, 100 | "ideal": { "name": "Waga idealna" }, 101 | "impedance": { "name": "Impedancja" }, 102 | "last_measurement_time": { "name": "Ostatnia godzina ważenia" }, 103 | "lean_body_mass": { "name": "Beztłuszczowa masa ciała" }, 104 | "metabolic_age": { "name": "Wiek metaboliczny" }, 105 | "muscle_mass": { "name": "Masa mięśniowa" }, 106 | "problem": { 107 | "name": "Błąd", 108 | "state": { 109 | "impedance_high": "Impedancja wysoka", 110 | "impedance_low": "Niska impedancja", 111 | "impedance_unavailable": "Impedancja niedostępna", 112 | "none": "Brak", 113 | "weight_high": "Waga wysoka", 114 | "weight_high_and_impedance_high": "Waga i impedancja wysoka", 115 | "weight_high_and_impedance_low": "Waga wysoka a impedancja niska", 116 | "weight_low": "Niska waga", 117 | "weight_low_and_impedance_high": "Waga nizska a impedancja wysoka", 118 | "weight_low_and_impedance_low": "Waga i impedancja niskie", 119 | "weight_unavailable": "Waga niedostępna", 120 | "weight_unavailable_and_impedance_unavailable": "Waga i impedancja niedostępne" 121 | } 122 | }, 123 | "protein": { "name": "Białko" }, 124 | "visceral_fat": { "name": "Tłuszcz trzewny" }, 125 | "water": { "name": "Woda" }, 126 | "weight": { "name": "Waga" } 127 | } 128 | } 129 | }, 130 | "options": { 131 | "step": { 132 | "init": { 133 | "data": { 134 | "height": "Wzrost", 135 | "impedance": "Sensor impedancji", 136 | "last_measurement_time": "Czujnik ostatniej godziny ważenia", 137 | "weight": "Sensor wagi" 138 | }, 139 | "data_description": { 140 | "impedance": "Jeśli waga nie podaje impedancji, pozostaw to pole puste.", 141 | "last_measurement_time": "Jeśli nie masz czujnika ostatniej godziny ważenia, pozostaw to pole puste." 142 | } 143 | } 144 | } 145 | }, 146 | "title": "BodyMiScale" 147 | } 148 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/translations/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "already_configured": "Ya configurado", 5 | "height_limit": "La altura es demasiado alta (limite: 220 cm)", 6 | "height_low": "La altura es demasiado baja (min : 50 cm)", 7 | "invalid_date": "Fecha invalida" 8 | }, 9 | "step": { 10 | "options": { 11 | "data": { 12 | "height": "Altura", 13 | "impedance": "Sensor de immpedancia", 14 | "last_measurement_time": "Sensor de la última hora de pesaje", 15 | "weight": "Sensor de peso" 16 | }, 17 | "data_description": { 18 | "impedance": "Si su báscula no proporciona la impedancia, deje este campo vacío.", 19 | "last_measurement_time": "Deje este campo en blanco si no tiene un sensor para la última hora de pesaje." 20 | } 21 | }, 22 | "user": { 23 | "data": { 24 | "birthday": "Cumpleaños", 25 | "gender": "Genero", 26 | "name": "Nombre" 27 | } 28 | } 29 | } 30 | }, 31 | "entity": { 32 | "sensor": { 33 | "basal_metabolism": { "name": "Metabolismo basal" }, 34 | "bmi": { "name": "IMC" }, 35 | "body_fat": { "name": "Grasa corporal" }, 36 | "body_score": { "name": "Puntuación corporal" }, 37 | "bone_mass": { "name": "Masa ósea" }, 38 | "last_measurement_time": { "name": "Última hora de pesaje" }, 39 | "lean_body_mass": { "name": "Masa corporal magra" }, 40 | "metabolic_age": { "name": "Edad metabólica" }, 41 | "muscle_mass": { "name": "Masa muscular" }, 42 | "protein": { "name": "Proteína" }, 43 | "visceral_fat": { "name": "Grasa visceral" }, 44 | "water": { "name": "Agua" }, 45 | "weight": { "name": "Peso" } 46 | } 47 | }, 48 | "entity_component": { 49 | "_": { 50 | "name": "bodymiscale", 51 | "state": { 52 | "ok": "OK", 53 | "problem": "Problema" 54 | }, 55 | "state_attributes": { 56 | "age": { "name": "Edad" }, 57 | "basal_metabolism": { "name": "Metabolismo basal" }, 58 | "bmi": { "name": "IMC" }, 59 | "bmi_label": { 60 | "name": "Etiqueta IMC", 61 | "state": { 62 | "massive_obesity": "Obesidad masiva", 63 | "moderate_ obesity": "Obesidad moderada", 64 | "normal_or_healthy_weight": "Normal", 65 | "overweight": "Sobrepeso", 66 | "severe_obesity": "Obesidad severa", 67 | "slight_overweight": "Ligero sobrepeso", 68 | "unavailable": "no disponible", 69 | "underweight": "Por debajo del peso normal" 70 | } 71 | }, 72 | "body_fat": { "name": "Grasa corporal" }, 73 | "body_score": { "name": "Puntuación corporal" }, 74 | "body_type": { 75 | "name": "Tipo de cuerpo", 76 | "state": { 77 | "balanced": "Equilibrado", 78 | "balanced_muscular": "Musculuso equilibrado", 79 | "balanced_skinny": "Flaco equilibrado", 80 | "lack_exercise": "Falto de ejercicio", 81 | "obese": "Obeso", 82 | "overweight": "Sobrepeso", 83 | "skinny": "Flaco", 84 | "skinny_muscular": "Flaco musculoso", 85 | "thick_set": "Rechoncho", 86 | "unavailable": "no disponible" 87 | } 88 | }, 89 | "bone_mass": { "name": "Masa ósea" }, 90 | "fat_mass_to_gain": { "name": "Masa grasa a ganar" }, 91 | "fat_mass_to_lose": { "name": "Masa grasa a perder" }, 92 | "gender": { 93 | "name": "Sexo", 94 | "state": { 95 | "female": "femenino", 96 | "male": "masculino" 97 | } 98 | }, 99 | "height": { "name": "Altura" }, 100 | "ideal": { "name": "Ideal" }, 101 | "impedance": { "name": "Impedancia" }, 102 | "last_measurement_time": { "name": "Última hora de pesaje" }, 103 | "lean_body_mass": { "name": "Masa corporal magra" }, 104 | "metabolic_age": { "name": "Edad metabólica" }, 105 | "muscle_mass": { "name": "Masa muscular" }, 106 | "problem": { 107 | "name": "Problema", 108 | "state": { 109 | "impedance_high": "Impedancia alta", 110 | "impedance_low": "Impedancia baja", 111 | "impedance_unavailable": "Impedancia no disponible", 112 | "none": "Ninguno", 113 | "weight_high": "Peso alto", 114 | "weight_high_and_impedance_high": "Peso alto, impedancia alta", 115 | "weight_high_and_impedance_low": "Peso alto, impedancia baja", 116 | "weight_low": "Peso bajo", 117 | "weight_low_and_impedance_high": "Peso bajo, impedancia alta", 118 | "weight_low_and_impedance_low": "Peso bajo, impedancia baja", 119 | "weight_unavailable": "Peso no disponible", 120 | "weight_unavailable_and_impedance_unavailable": "Peso no disponible, impedancia no disponible" 121 | } 122 | }, 123 | "protein": { "name": "Proteína" }, 124 | "visceral_fat": { "name": "Grasa visceral" }, 125 | "water": { "name": "Agua" }, 126 | "weight": { "name": "Peso" } 127 | } 128 | } 129 | }, 130 | "options": { 131 | "step": { 132 | "init": { 133 | "data": { 134 | "height": "Altura", 135 | "impedance": "Sensor de immpedancia", 136 | "last_measurement_time": "Sensor de la última hora de pesaje", 137 | "weight": "Sensor de peso" 138 | }, 139 | "data_description": { 140 | "impedance": "Si su báscula no proporciona la impedancia, deje este campo vacío.", 141 | "last_measurement_time": "Deje este campo en blanco si no tiene un sensor para la última hora de pesaje." 142 | } 143 | } 144 | } 145 | }, 146 | "title": "BodyMiScale" 147 | } 148 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/translations/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "already_configured": "Já configurado", 5 | "height_limit": "Altura é muito alta (limite: 220 cm)", 6 | "height_low": "Altura é muito baixa (min : 50 cm)", 7 | "invalid_date": "Data inválida" 8 | }, 9 | "step": { 10 | "options": { 11 | "data": { 12 | "height": "Altura", 13 | "impedance": "Sensor de impedância", 14 | "last_measurement_time": "Sensor da última hora de pesagem", 15 | "weight": "Sensor de peso" 16 | }, 17 | "data_description": { 18 | "impedance": "Se sua balança não fornecer a impedância, deixe este campo vazio.", 19 | "last_measurement_time": "Se você não tem um sensor para a última hora de pesagem, deixe este campo em branco." 20 | } 21 | }, 22 | "user": { 23 | "data": { 24 | "birthday": "Data de nascimento", 25 | "gender": "Gênero", 26 | "name": "Nome" 27 | } 28 | } 29 | } 30 | }, 31 | "entity": { 32 | "sensor": { 33 | "basal_metabolism": { "name": "Metabolismo basal" }, 34 | "bmi": { "name": "IMC" }, 35 | "body_fat": { "name": "Gordura corporal" }, 36 | "body_score": { "name": "Escore corporal" }, 37 | "bone_mass": { "name": "Massa óssea" }, 38 | "last_measurement_time": { "name": "Última hora de pesagem" }, 39 | "lean_body_mass": { "name": "Massa corporal magra" }, 40 | "metabolic_age": { "name": "Idade metabólica" }, 41 | "muscle_mass": { "name": "Massa muscular" }, 42 | "protein": { "name": "Proteína" }, 43 | "visceral_fat": { "name": "Gordura visceral" }, 44 | "water": { "name": "Água" }, 45 | "weight": { "name": "Peso" } 46 | } 47 | }, 48 | "entity_component": { 49 | "_": { 50 | "name": "bodymiscale", 51 | "state": { 52 | "ok": "OK", 53 | "problem": "Problema" 54 | }, 55 | "state_attributes": { 56 | "age": { "name": "Idade" }, 57 | "basal_metabolism": { "name": "Metabolismo basal" }, 58 | "bmi": { "name": "IMC" }, 59 | "bmi_label": { 60 | "name": "Etiqueta IMC", 61 | "state": { 62 | "massive_obesity": "Obesidade maciça", 63 | "moderate_obesity": "Obesidade moderada", 64 | "normal_or_healthy_weight": "Normal", 65 | "overweight": "Sobrepeso", 66 | "severe_obesity": "Obesidade severa", 67 | "slight_overweight": "Ligeiro acima do peso", 68 | "unavailable": "indisponível", 69 | "underweight": "Underweight" 70 | } 71 | }, 72 | "body_fat": { "name": "Gordura corporal" }, 73 | "body_score": { "name": "Escore corporal" }, 74 | "body_type": { 75 | "name": "Tipo de corpo", 76 | "state": { 77 | "balanced": "Equilibrado", 78 | "balanced_muscular": "Musculoso equilibrado", 79 | "balanced_skinny": "Magro equilibrado", 80 | "lack_exercise": "Falta de exercício", 81 | "obese": "Obeso", 82 | "overweight": "Sobrepeso", 83 | "skinny": "Magro", 84 | "skinny_muscular": "Magro musculoso", 85 | "thick_set": "Grosso-conjunto", 86 | "unavailable": "indisponível" 87 | } 88 | }, 89 | "bone_mass": { "name": "Massa óssea" }, 90 | "fat_mass_to_gain": { "name": "Massa gorda a ganhar" }, 91 | "fat_mass_to_lose": { "name": "Massa gorda a perder" }, 92 | "gender": { 93 | "name": "Gênero", 94 | "state": { 95 | "female": "fêmea", 96 | "male": "macho" 97 | } 98 | }, 99 | "height": { "name": "Cintura" }, 100 | "ideal": { "name": "Ideal" }, 101 | "impedance": { "name": "Impedance" }, 102 | "last_measurement_time": { "name": "Última hora de pesagem" }, 103 | "lean_body_mass": { "name": "Massa corporal magra" }, 104 | "metabolic_age": { "name": "Idade metabólica" }, 105 | "muscle_mass": { "name": "Massa muscular" }, 106 | "problem": { 107 | "name": "Problema", 108 | "state": { 109 | "impedance_high": "Impedância alta", 110 | "impedance_low": "Impedância baixa", 111 | "impedance_unavailable": "Impedance indisponível", 112 | "none": "Nenhum", 113 | "weight_high": "Peso alto", 114 | "weight_high_and_impedance_high": "Peso alto, impedância alta", 115 | "weight_high_and_impedance_low": "Peso alto, impedância baixa", 116 | "weight_low": "Peso baixo", 117 | "weight_low_and_impedance_high": "Peso baixo, impedância alta", 118 | "weight_low_and_impedance_low": "Peso baixo, impedância baixa", 119 | "weight_unavailable": "Peso indisponível", 120 | "weight_unavailable_and_impedance_unavailable": "Peso indisponível, impedance indisponível" 121 | } 122 | }, 123 | "protein": { "name": "Proteína" }, 124 | "visceral_fat": { "name": "Gordura visceral" }, 125 | "water": { "name": "Água" }, 126 | "weight": { "name": "Peso" } 127 | } 128 | } 129 | }, 130 | "options": { 131 | "step": { 132 | "init": { 133 | "data": { 134 | "height": "Altura", 135 | "impedance": "Sensor de impedância", 136 | "last_measurement_time": "Sensor da última hora de pesagem", 137 | "weight": "Sensor de peso" 138 | }, 139 | "data_description": { 140 | "impedance": "Se sua balança não fornecer a impedância, deixe este campo vazio.", 141 | "last_measurement_time": "Se você não tem um sensor para a última hora de pesagem, deixe este campo em branco." 142 | } 143 | } 144 | } 145 | }, 146 | "title": "BodyMiScale" 147 | } 148 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/translations/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "already_configured": "Уже настроено", 5 | "height_limit": "Рост слишком большой (предел: 220 см)", 6 | "height_low": "Рост слишком низкая (min : 50 см)", 7 | "invalid_date": "Неверная дата" 8 | }, 9 | "step": { 10 | "options": { 11 | "data": { 12 | "height": "Рост", 13 | "impedance": "Датчик сопротивления", 14 | "last_measurement_time": "Датчик последнего времени взвешивания", 15 | "weight": "Датчик веса" 16 | }, 17 | "data_description": { 18 | "impedance": "Если Ваши весы не измеряют сопротивление, оставьте это поле пустым.", 19 | "last_measurement_time": "Если у вас нет датчика последнего времени взвешивания, оставьте это поле пустым." 20 | } 21 | }, 22 | "user": { 23 | "data": { 24 | "birthday": "Дата рождения", 25 | "gender": "Пол", 26 | "name": "Имя" 27 | } 28 | } 29 | } 30 | }, 31 | "entity": { 32 | "sensor": { 33 | "basal_metabolism": { "name": "Базальный метаболизм" }, 34 | "bmi": { "name": "Индекс BMI" }, 35 | "body_fat": { "name": "Жировая ткань" }, 36 | "body_score": { "name": "Оценка тела" }, 37 | "bone_mass": { "name": "Костная масса" }, 38 | "last_measurement_time": { "name": "Последнее время взвешивания" }, 39 | "lean_body_mass": { "name": "Сухая масса тела" }, 40 | "metabolic_age": { "name": "Метаболический возраст" }, 41 | "muscle_mass": { "name": "Мышечная масса" }, 42 | "protein": { "name": "Белки" }, 43 | "visceral_fat": { "name": "Висцеральный жир" }, 44 | "water": { "name": "Вода" }, 45 | "weight": { "name": "Вес" } 46 | } 47 | }, 48 | "entity_component": { 49 | "_": { 50 | "name": "bodymiscale", 51 | "state": { 52 | "ok": "OK", 53 | "problem": "Проблема" 54 | }, 55 | "state_attributes": { 56 | "age": { "name": "Возраст" }, 57 | "basal_metabolism": { "name": "Базальный метаболизм" }, 58 | "bmi": { "name": "Индекс BMI" }, 59 | "bmi_label": { 60 | "name": "Интерпретация BMI", 61 | "state": { 62 | "massive_obesity": "Ожирение 3й степени", 63 | "moderate_obesity": "Ожирение 1й степени", 64 | "normal_or_healthy_weight": "Нормальный вес", 65 | "overweight": "Лишний вес", 66 | "severe_obesity": "Ожирение 2й степени", 67 | "slight_overweight": "Избыточный вес", 68 | "unavailable": "недоступен", 69 | "underweight": "Недостаточный вес" 70 | } 71 | }, 72 | "body_fat": { "name": "Жировая ткань" }, 73 | "body_score": { "name": "Оценка тела" }, 74 | "body_type": { 75 | "name": "Тип тела", 76 | "state": { 77 | "balanced": "Оптимальный", 78 | "balanced_muscular": "Мускулистый", 79 | "balanced_skinny": "Худощавый", 80 | "lack_exercise": "Недостаток упражнений", 81 | "obese": "Ожирение", 82 | "overweight": "Лишний вес", 83 | "skinny": "Тощий", 84 | "skinny_muscular": "Подтянуто-мускулистый", 85 | "thick_set": "Коренастый", 86 | "unavailable": "недоступен" 87 | } 88 | }, 89 | "bone_mass": { "name": "Костная масса" }, 90 | "fat_mass_to_gain": { "name": "Набираемая жировая масса" }, 91 | "fat_mass_to_lose": { "name": "Потеря жировой массы" }, 92 | "gender": { 93 | "name": "Пол", 94 | "state": { 95 | "female": "женский", 96 | "male": "мужской" 97 | } 98 | }, 99 | "height": { "name": "Рост" }, 100 | "ideal": { "name": "Идеальный вес" }, 101 | "impedance": { "name": "Импеданс" }, 102 | "last_measurement_time": { "name": "Последнее время взвешивания" }, 103 | "lean_body_mass": { "name": "Сухая масса тела" }, 104 | "metabolic_age": { "name": "Метаболический возраст" }, 105 | "muscle_mass": { "name": "Мышечная масса" }, 106 | "problem": { 107 | "name": "Проблема", 108 | "state": { 109 | "impedance_high": "Высокий биоимпеданс", 110 | "impedance_low": "Низкий биоимпеданс", 111 | "impedance_unavailable": "Биоимпеданс недоступен", 112 | "none": "Нет", 113 | "weight_high": "Высокий вес", 114 | "weight_high_and_impedance_high": "Высокие вес и биоимпеданс", 115 | "weight_high_and_impedance_low": "Высокий вес, низкий биоимпеданс", 116 | "weight_low": "Низкий вес", 117 | "weight_low_and_impedance_high": "Низкий вес, высокий биоимпеданс", 118 | "weight_low_and_impedance_low": "Низкие вес и биоимпеданс", 119 | "weight_unavailable": "Вес недоступен", 120 | "weight_unavailable_and_impedance_unavailable": "Вес и биоимпеданс недоступны" 121 | } 122 | }, 123 | "protein": { "name": "Белки" }, 124 | "visceral_fat": { "name": "Висцеральный жир" }, 125 | "water": { "name": "Вода" }, 126 | "weight": { "name": "Вес" } 127 | } 128 | } 129 | }, 130 | "options": { 131 | "step": { 132 | "init": { 133 | "data": { 134 | "height": "Рост", 135 | "impedance": "Датчик сопротивления", 136 | "last_measurement_time": "Датчик последнего времени взвешивания", 137 | "weight": "Датчик веса" 138 | }, 139 | "data_description": { 140 | "impedance": "Если Ваши весы не измеряют сопротивление, оставьте это поле пустым.", 141 | "last_measurement_time": "Если у вас нет датчика последнего времени взвешивания, оставьте это поле пустым." 142 | } 143 | } 144 | } 145 | }, 146 | "title": "BodyMiScale" 147 | } 148 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "already_configured": "Déjà configuré", 5 | "height_limit": "La taille est trop élevée (limite : 220 cm)", 6 | "height_low": "La taille est trop basse (min : 50 cm)", 7 | "invalid_date": "Date non valide" 8 | }, 9 | "step": { 10 | "options": { 11 | "data": { 12 | "height": "Taille", 13 | "impedance": "Capteur d'impédance", 14 | "last_measurement_time": "Capteur de la dernière pesée", 15 | "weight": "Capteur de poids" 16 | }, 17 | "data_description": { 18 | "impedance": "Si votre balance ne fournit pas l'impédance, laissez ce champ vide.", 19 | "last_measurement_time": "Si vous n'avez pas de capteur pour la dernière heure de pesée, laissez ce champ vide." 20 | } 21 | }, 22 | "user": { 23 | "data": { 24 | "birthday": "Date d'anniversaire", 25 | "gender": "Genre", 26 | "name": "Nom" 27 | } 28 | } 29 | } 30 | }, 31 | "entity": { 32 | "sensor": { 33 | "basal_metabolism": { "name": "Métabolisme de base" }, 34 | "bmi": { "name": "IMC" }, 35 | "body_fat": { "name": "Graisse corporelle" }, 36 | "body_score": { "name": "Score corporel" }, 37 | "bone_mass": { "name": "Masse osseuse" }, 38 | "last_measurement_time": { "name": "Dernière heure de pesée" }, 39 | "lean_body_mass": { "name": "Masse corporelle mince" }, 40 | "metabolic_age": { "name": "Age corporel" }, 41 | "muscle_mass": { "name": "Masse musculaire" }, 42 | "protein": { "name": "Protéine" }, 43 | "visceral_fat": { "name": "Graisse viscérale" }, 44 | "water": { "name": "Water" }, 45 | "weight": { "name": "Poids" } 46 | } 47 | }, 48 | "entity_component": { 49 | "_": { 50 | "name": "bodymiscale", 51 | "state": { 52 | "ok": "OK", 53 | "problem": "Problème" 54 | }, 55 | "state_attributes": { 56 | "age": { "name": "Age" }, 57 | "basal_metabolism": { "name": "Métabolisme de base" }, 58 | "bmi": { "name": "IMC" }, 59 | "bmi_label": { 60 | "name": "Étiquette IMC", 61 | "state": { 62 | "massive_obesity": "Obésité massive", 63 | "moderate_obesity": "Obésité modérée", 64 | "normal_or_healthy_weight": "Normal - poids de santé", 65 | "overweight": "Surpoids", 66 | "severe_obesity": "Obésité sévère", 67 | "slight_overweight": "Léger surpoids", 68 | "unavailable": "indisponible", 69 | "underweight": "Insuffisance pondérale" 70 | } 71 | }, 72 | "body_fat": { "name": "Graisse corporelle" }, 73 | "body_score": { "name": "Score corporel" }, 74 | "body_type": { 75 | "name": "Corpulence", 76 | "state": { 77 | "balanced": "Équilibré", 78 | "balanced_muscular": "Musclé équilibré", 79 | "balanced_skinny": "Équilibré maigre", 80 | "lack_exercise": "Manque d'exercice", 81 | "obese": "Obèse", 82 | "overweight": "Surpoids", 83 | "skinny": "Maigre", 84 | "skinny_muscular": "Maigre musclé", 85 | "thick_set": "Trapu", 86 | "unavailable": "indisponible" 87 | } 88 | }, 89 | "bone_mass": { "name": "Masse osseuse" }, 90 | "fat_mass_to_gain": { "name": "Masse grasse à gagner" }, 91 | "fat_mass_to_lose": { "name": "Masse grasse à perdre" }, 92 | "gender": { 93 | "name": "Genre", 94 | "state": { 95 | "female": "femme", 96 | "male": "homme" 97 | } 98 | }, 99 | "height": { "name": "Taille" }, 100 | "ideal": { "name": "Poids idéal" }, 101 | "impedance": { "name": "Impédance" }, 102 | "last_measurement_time": { "name": "Dernière heure de pesée" }, 103 | "lean_body_mass": { "name": "Masse corporelle mince" }, 104 | "metabolic_age": { "name": "Age corporel" }, 105 | "muscle_mass": { "name": "Masse musculaire" }, 106 | "problem": { 107 | "name": "Problème", 108 | "state": { 109 | "impedance_high": "Impédance élevé", 110 | "impedance_low": "Impédance faible", 111 | "impedance_unavailable": "Impédance indisponible", 112 | "none": "Aucun", 113 | "weight_high": "Poids élevé", 114 | "weight_high_and_impedance_high": "Poids élevé, impédance élevé", 115 | "weight_high_and_impedance_low": "Poids élevé, impédance faible", 116 | "weight_low": "Poids faible", 117 | "weight_low_and_impedance_high": "Poids faible, impédance élevé", 118 | "weight_low_and_impedance_low": "Poids faible, impédance faible", 119 | "weight_unavailable": "Poids indisponible", 120 | "weight_unavailable_and_impedance_unavailable": "Poids indisponible, impédance indisponible" 121 | } 122 | }, 123 | "protein": { "name": "Protéine" }, 124 | "visceral_fat": { "name": "Graisse viscérale" }, 125 | "water": { "name": "Eau" }, 126 | "weight": { "name": "Poids" } 127 | } 128 | } 129 | }, 130 | "options": { 131 | "step": { 132 | "init": { 133 | "data": { 134 | "height": "Taille", 135 | "impedance": "Capteur d'impédance", 136 | "last_measurement_time": "Capteur de la dernière pesée", 137 | "weight": "Capteur de poids" 138 | }, 139 | "data_description": { 140 | "impedance": "Si votre balance ne fournit pas l'impédance, laissez ce champ vide.", 141 | "last_measurement_time": "Si vous n'avez pas de capteur pour la dernière heure de pesée, laissez ce champ vide." 142 | } 143 | } 144 | } 145 | }, 146 | "title": "BodyMiScale" 147 | } 148 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/translations/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "already_configured": "Già configurato", 5 | "height_limit": "Altezza troppo elevata (limite: 220 cm)", 6 | "height_low": "Altezza troppo bassa (minimum : 50 cm)", 7 | "invalid_date": "Data non valida" 8 | }, 9 | "step": { 10 | "options": { 11 | "data": { 12 | "height": "Altezza", 13 | "impedance": "Sensore impedenza", 14 | "last_measurement_time": "Sensore dell'ultima ora di pesatura", 15 | "weight": "Sensore peso" 16 | }, 17 | "data_description": { 18 | "impedance": "Se la tua bilancia non fornisce il valore di impedenza lascia questo campo vuoto.", 19 | "last_measurement_time": "Se non hai un sensore per l'ultima ora di pesatura, lascia questo campo vuoto." 20 | } 21 | }, 22 | "user": { 23 | "data": { 24 | "birthday": "Data di nascita", 25 | "gender": "Genere", 26 | "name": "Nome" 27 | } 28 | } 29 | } 30 | }, 31 | "entity": { 32 | "sensor": { 33 | "basal_metabolism": { "name": "Metabolismo base" }, 34 | "bmi": { "name": "BMI" }, 35 | "body_fat": { "name": "Grasso corporeo" }, 36 | "body_score": { "name": "Punteggio corpo" }, 37 | "bone_mass": { "name": "Massa ossea" }, 38 | "last_measurement_time": { "name": "Ultima ora di pesatura" }, 39 | "lean_body_mass": { "name": "Massa corporea magra" }, 40 | "metabolic_age": { "name": "Età metabolica" }, 41 | "muscle_mass": { "name": "Massa muscolare" }, 42 | "protein": { "name": "Proteine" }, 43 | "visceral_fat": { "name": "Grasso viscerale" }, 44 | "water": { "name": "Acqua" }, 45 | "weight": { "name": "Peso" } 46 | } 47 | }, 48 | "entity_component": { 49 | "_": { 50 | "name": "bodymiscale", 51 | "state": { 52 | "ok": "OK", 53 | "problem": "Problema" 54 | }, 55 | "state_attributes": { 56 | "age": { "name": "Età" }, 57 | "basal_metabolism": { "name": "Metabolismo base" }, 58 | "bmi": { "name": "BMI" }, 59 | "bmi_label": { 60 | "name": "BMI Categoria", 61 | "state": { 62 | "massive_obesity": "Obesità Massiccia", 63 | "moderate_obesity": "Obesità Moderata", 64 | "normal_or_healthy_weight": "Normale o Peso Sano", 65 | "overweight": "Sovrappeso", 66 | "severe_obesity": "Obesità Grave", 67 | "slight_overweight": "Leggermente in sovrappeso", 68 | "unavailable": "non disponibile", 69 | "underweight": "Sottopeso" 70 | } 71 | }, 72 | "body_fat": { "name": "Grasso corporeo" }, 73 | "body_score": { "name": "Punteggio corpo" }, 74 | "body_type": { 75 | "name": "Tipo di corpo", 76 | "state": { 77 | "balanced": "Bilanciato", 78 | "balanced_muscular": "Bilanciato-muscoloso", 79 | "balanced_skinny": "Bilanciato-magro", 80 | "lack_exercise": "Manca-esercizio", 81 | "obese": "Obeso", 82 | "overweight": "Sovrappeso", 83 | "skinny": "Magro", 84 | "skinny_muscular": "Magro-muscoloso", 85 | "thick_set": "Spesso-impostato", 86 | "unavailable": "non disponibile" 87 | } 88 | }, 89 | "bone_mass": { "name": "Massa ossea" }, 90 | "fat_mass_to_gain": { "name": "Massa grassa da acquisire" }, 91 | "fat_mass_to_lose": { "name": "Massa grassa da perdere" }, 92 | "gender": { 93 | "name": "Sesso", 94 | "state": { 95 | "female": "donna", 96 | "male": "uomo" 97 | } 98 | }, 99 | "height": { "name": "Altezza" }, 100 | "ideal": { "name": "Ideale" }, 101 | "impedance": { "name": "Impedenza" }, 102 | "last_measurement_time": { "name": "Ultima ora di pesatura" }, 103 | "lean_body_mass": { "name": "Massa corporea magra" }, 104 | "metabolic_age": { "name": "Età metabolica" }, 105 | "muscle_mass": { "name": "Massa muscolare" }, 106 | "problem": { 107 | "name": "Problema", 108 | "state": { 109 | "impedance high": "Impedenza alta", 110 | "impedance_low": "Impedenza bassa", 111 | "impedance_unavailable": "Impedenza non disponibile", 112 | "none": "Nessuno", 113 | "weight_high": "Peso alto", 114 | "weight_high_and_impedance_high": "Peso alto, impedenza alta", 115 | "weight_high_and_impedance_low": "Peso alto, impedenza bassa", 116 | "weight_low": "Peso basso", 117 | "weight_low_and_impedance_high": "Peso basso, impedenza alta", 118 | "weight_low_and_impedance_low": "Peso basso, impedenza bassa", 119 | "weight_unavailable": "Peso non disponibile", 120 | "weight_unavailable_and_impedance_unavailable": "Peso non disponibile, impedenza non disponibile" 121 | } 122 | }, 123 | "protein": { "name": "Proteine" }, 124 | "visceral_fat": { "name": "Grasso viscerale" }, 125 | "water": { "name": "Acqua" }, 126 | "weight": { "name": "Peso" } 127 | } 128 | } 129 | }, 130 | "options": { 131 | "step": { 132 | "init": { 133 | "data": { 134 | "height": "Altezza", 135 | "impedance": "Sensore impedenza", 136 | "last_measurement_time": "Sensore dell'ultima ora di pesatura", 137 | "weight": "Sensore peso" 138 | }, 139 | "data_description": { 140 | "impedance": "Se la tua bilancia non fornisce il valore di impedenza lascia questo campo vuoto.", 141 | "last_measurement_time": "Se non hai un sensore per l'ultima ora di pesatura, lascia questo campo vuoto." 142 | } 143 | } 144 | } 145 | }, 146 | "title": "BodyMiScale" 147 | } 148 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/translations/ro.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "error": { 4 | "already_configured": "Deja configurat", 5 | "height_limit": "Înalțimea este prea mare (limita: 220 cm)", 6 | "height_low": "Înalțimea este prea joasă (min : 50 cm)", 7 | "invalid_date": "Dată invalidă" 8 | }, 9 | "step": { 10 | "options": { 11 | "data": { 12 | "height": "Înalțime", 13 | "impedance": "Senzor impedanță", 14 | "last_measurement_time": "Senzor pentru ultima oră de cântărire", 15 | "weight": "Senzor greutate" 16 | }, 17 | "data_description": { 18 | "impedance": "Dacă cântarul dumneavoastra nu furnizează impedanță, lăsați câmpul necompletat.", 19 | "last_measurement_time": "Dacă nu aveți un senzor pentru ultima oră de cântărire, lăsați acest câmp gol." 20 | } 21 | }, 22 | "user": { 23 | "data": { 24 | "birthday": "Dată naștere", 25 | "gender": "Gen", 26 | "name": "Nume" 27 | } 28 | } 29 | } 30 | }, 31 | "entity": { 32 | "sensor": { 33 | "basal_metabolism": { "name": "Metabolismul bazal" }, 34 | "bmi": { "name": "IMC" }, 35 | "body_fat": { "name": "Grăsime corporală" }, 36 | "body_score": { "name": "Scor corporal" }, 37 | "bone_mass": { "name": "Masă osoasă" }, 38 | "last_measurement_time": { "name": "Ultima oră de cântărire" }, 39 | "lean_body_mass": { "name": "Masă corporală slabă" }, 40 | "metabolic_age": { "name": "Vârsta metabolică" }, 41 | "muscle_mass": { "name": "Masă musculară" }, 42 | "protein": { "name": "Proteină" }, 43 | "visceral_fat": { "name": "Grasime viscerala" }, 44 | "water": { "name": "Apă" }, 45 | "weight": { "name": "Greutate" } 46 | } 47 | }, 48 | "entity_component": { 49 | "_": { 50 | "name": "bodymiscale", 51 | "state": { 52 | "ok": "Bine", 53 | "problem": "Problemă" 54 | }, 55 | "state_attributes": { 56 | "age": { "name": "Vârstă" }, 57 | "basal_metabolism": { "name": "Metabolismul bazal" }, 58 | "bmi": { "name": "IMC" }, 59 | "bmi_label": { 60 | "name": "Eticheta IMC", 61 | "state": { 62 | "massive_obesity": "Obezitate masivă", 63 | "moderate_obesity": "Obezitate moderată", 64 | "normal_or_healthy_weight": "Greutate normală sau sănătoasă", 65 | "overweight": "Supraponderal", 66 | "severe_obesity": "Obezitate severă", 67 | "slight_overweight": "Ușor supraponderal", 68 | "unavailable": "indisponibil", 69 | "underweight": "Subponderal" 70 | } 71 | }, 72 | "body_fat": { "name": "Grăsime corporală" }, 73 | "body_score": { "name": "Scor corporal" }, 74 | "body_type": { 75 | "name": "Tipul corpului", 76 | "state": { 77 | "balanced": "Echilibrat", 78 | "balanced_muscular": "Balanced-muscular", 79 | "balanced_skinny": "Slab-echilibrat", 80 | "lack_exercise": "Lipsa-exercițiu", 81 | "obese": "Obez", 82 | "overweight": "Supraponderal", 83 | "skinny": "Slab", 84 | "skinny_muscular": "Slab-muscular", 85 | "thick_set": "Îndesat", 86 | "unavailable": "indisponibil" 87 | } 88 | }, 89 | "bone_mass": { "name": "Masă osoasă" }, 90 | "fat_mass_to_gain": { "name": "Masa grasă pentru a câștiga" }, 91 | "fat_mass_to_lose": { "name": "Masă grasă pentru a pierde" }, 92 | "gender": { 93 | "name": "Gen", 94 | "state": { 95 | "female": "feminin", 96 | "male": "masculin" 97 | } 98 | }, 99 | "height": { "name": "Înălţime" }, 100 | "ideal": { "name": "Ideal" }, 101 | "impedance": { "name": "Impedanță" }, 102 | "last_measurement_time": { "name": "Ultima oră de cântărire" }, 103 | "lean_body_mass": { "name": "Masă corporală slabă" }, 104 | "metabolic_age": { "name": "Vârsta metabolică" }, 105 | "muscle_mass": { "name": "Masă musculară" }, 106 | "problem": { 107 | "name": "Problemă", 108 | "state": { 109 | "impedance_high": "Impedanță mare", 110 | "impedance_low": "Impedanță scăzută", 111 | "impedance_unavailable": "Impedanță indisponibilă", 112 | "none": "Nimic", 113 | "weight_high": "Greutate mare", 114 | "weight_high_and_impedance_high": "Greutate mare, impedanță mare", 115 | "weight_high_and_impedance_low": "Greutate mare, impedanță scăzută", 116 | "weight_low": "Greutate redusă", 117 | "weight_low_and_impedance_high": "Greutate redusă, impedanță ridicată", 118 | "weight_low_and_impedance_low": "Greutate redusă, impedanță scăzută", 119 | "weight_unavailable": "Greutate indisponibilă", 120 | "weight_unavailable_and_impedance_unavailable": "Greutate indisponibilă, impedanță indisponibilă" 121 | } 122 | }, 123 | "protein": { "name": "Proteină" }, 124 | "visceral_fat": { "name": "Grasime viscerala" }, 125 | "water": { "name": "Apă" }, 126 | "weight": { "name": "Greutate" } 127 | } 128 | } 129 | }, 130 | "options": { 131 | "step": { 132 | "init": { 133 | "data": { 134 | "height": "Înalțime", 135 | "impedance": "Senzor impedanță", 136 | "last_measurement_time": "Senzor pentru ultima oră de cântărire", 137 | "weight": "Senzor greutate" 138 | }, 139 | "data_description": { 140 | "impedance": "Dacă cântarul dumneavoastra nu furnizează impedanță, lăsați câmpul necompletat.", 141 | "last_measurement_time": "Dacă nu aveți un senzor pentru ultima oră de cântărire, lăsați acest câmp gol." 142 | } 143 | } 144 | } 145 | }, 146 | "title": "BodyMiScale" 147 | } 148 | -------------------------------------------------------------------------------- /example_config/README.md: -------------------------------------------------------------------------------- 1 | # Example configurations for Xiaomi Mi Scale with multi-user management 2 | 3 | This project offers three example configurations for integrating a Xiaomi Mi Scale with Home Assistant, with an emphasis on multi-user management and data persistence. 4 | 5 | ## Example 1: Complete Management by ESPHome 6 | 7 | - **File:** `esphome_configuration.yaml` 8 | - **Description:** This example demonstrates how to configure ESPHome to manage all sensors (weight, impedance, last weighing time) directly. ESPHome is responsible for data persistence and associating measurements with users based on weight ranges. 9 | - **Advantages:** 10 | - Autonomy: ESPHome handles all logic, reducing the load on Home Assistant. 11 | - Data Persistence: Data is retained even after an ESPHome restart. 12 | - **Usage:** Ideal for users who want a robust and self-contained solution. 13 | 14 | ## Example 2: ESPHome or BLE Monitor + Home Assistant (User Management by HA) 15 | 16 | - **Files:** 17 | - `esphome_base_configuration.yaml`: Basic ESPHome configuration to provide raw data to Home Assistant. 18 | - `weight_impedance_update.yaml`: Home Assistant configuration to manage user logic. 19 | - **Description:** This example demonstrates how to configure ESPHome to provide raw data (weight, impedance, last weighing time) to Home Assistant. Home Assistant is then used to manage user logic (measurement association, user data persistence). 20 | [![Open your Home Assistant instance and show the blueprint import dialog with a specific blueprint pre-filled.](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https://github.com/dckiller51/bodymiscale/blob/main/example_config/weight_impedance_update.yaml) 21 | - **Advantages:** 22 | - Flexibility: Allows for extensive customization of user management in Home Assistant. 23 | - Home Assistant Integration: Leverages Home Assistant's features for user data management. 24 | - **Usage:** Ideal for users who want advanced customization of user management in Home Assistant. 25 | 26 | ## Common Features 27 | 28 | - **Multi-User Management:** Both examples support managing multiple users (up to 5). 29 | - **Data Persistence:** Data is retained even after an ESPHome restart (in Example 1) or a Home Assistant restart (in Example 2). 30 | - **Flexibility for Scales Without Impedance:** Users can easily adapt the configurations to their scales. 31 | - **Weight Range Filtering:** Measurements are associated with users based on configurable weight ranges. 32 | 33 | ## Example 3: Home Assistant Blueprint for Interactive User Selection 34 | 35 | - **File:** `interactive_notification_user_selection_weight_data_update.yaml` (Example Automation using the Blueprint) 36 | - **Description:** This example utilizes a Home Assistant Blueprint to send an interactive notification when a weight measurement is detected. Users can select who is on the scale, and the blueprint updates the corresponding weight (and optionally impedance/last weigh-in) input numbers/datetimes in Home Assistant. This method requires the Mobile Home Assistant app to receive and respond to the notification. 37 | [![Open your Home Assistant instance and show the blueprint import dialog with a specific blueprint pre-filled.](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https://github.com/dckiller51/bodymiscale/blob/main/example_config/interactive_notification_user_selection_weight_data_update.yaml) 38 | - **Advantages:** 39 | - Interactive: Provides a user-friendly way to identify who is being weighed. 40 | - Flexible User Management: User configuration is done directly in the Home Assistant automation created from the blueprint. 41 | - No Weight Range Configuration: Relies on direct user interaction for identification. 42 | - **Usage:** Ideal for users who prefer a direct, interactive approach to user identification and want to manage user data within Home Assistant using input helpers. 43 | 44 | **Interactive Notification Screenshot:** 45 | 46 | ![Screenshot of Interactive Scale Notification](/example_config/screenshot_phone_notification.jpg) 47 | 48 | ## Common Features 49 | 50 | - **Multi-User Management:** All examples support managing multiple users. 51 | - **Data Persistence:** Data is retained in ESPHome (Example 1), Home Assistant input helpers (Example 2 & 3). 52 | - **Flexibility for Scales Without Impedance:** Users can easily adapt the configurations to their scales. 53 | - **User Identification:** Examples 1 & 2 use weight ranges, while Example 3 uses interactive notifications. 54 | 55 | ## Configuration 56 | 57 | 1. **ESPHome Installation (for Examples 1 & 2):** Ensure you have ESPHome installed and configured for your ESP32 device. 58 | 2. **BLE Monitor Installation (for Example 2 & potentially triggering Example 3):** Ensure you have BLE Monitor installed if you are using it to receive data. 59 | 3. **Mobile Home Assistant App (for Example 3):** Ensure you have the Mobile Home Assistant app installed on your devices to receive interactive notifications. 60 | 4. **Secrets Configuration (for ESPHome):** Create a `secrets.yaml` file to store your sensitive information (Wi-Fi SSID, password, etc.). 61 | 5. **Example Selection:** Choose the desired configuration example (ESPHome Direct, ESPHome/BLE Monitor + HA Logic, or HA Blueprint). 62 | 6. **User Configuration:** 63 | - **ESPHome Direct:** Configure user names, weight ranges, and other parameters in `esphome_configuration.yaml`. 64 | - **ESPHome/BLE Monitor + HA Logic:** Configure user logic in the Home Assistant automation (`weight_impedance_update.yaml`). 65 | - **HA Blueprint:** Create a new automation from the `interactive_notification_user_selection_weight_data_update.yaml` (you'll need to add the blueprint file to your `blueprints/automation/` folder) and configure user names and input helper entities in the automation's settings. 66 | 7. **Configuration Upload (for ESPHome):** Upload the ESPHome configuration to your ESP32 device. 67 | 8. **Home Assistant Integration:** Sensors and automations will be automatically discovered or need to be created in Home Assistant. You can add them to your dashboards. 68 | 69 | ## Code Examples 70 | 71 | This directory contains the following configuration files: 72 | 73 | - **`esphome_configuration.yaml`:** Complete ESPHome configuration to manage all sensors directly. 74 | - **`esphome_base_configuration.yaml`:** Basic ESPHome configuration to provide raw data to Home Assistant (used in Example 2). 75 | - **`weight_impedance_update.yaml`:** Home Assistant configuration to manage user logic (used in Example 2). 76 | - **`interactive_notification_user_selection_weight_data_update.yaml`**: The Home Assistant blueprint for interactive user selection and weight data update (used in Example 3) 77 | 78 | Please refer to these files for detailed code examples and specific configurations. 79 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow to configure the bodymiscale integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | from types import MappingProxyType 6 | from typing import Any 7 | 8 | import homeassistant.helpers.config_validation as cv 9 | import voluptuous as vol 10 | from homeassistant.config_entries import ( 11 | ConfigEntry, 12 | ConfigFlow, 13 | ConfigFlowResult, 14 | OptionsFlow, 15 | ) 16 | from homeassistant.const import CONF_NAME 17 | from homeassistant.core import callback 18 | from homeassistant.helpers import selector 19 | 20 | from .const import ( 21 | CONF_BIRTHDAY, 22 | CONF_GENDER, 23 | CONF_HEIGHT, 24 | CONF_SENSOR_IMPEDANCE, 25 | CONF_SENSOR_LAST_MEASUREMENT_TIME, 26 | CONF_SENSOR_WEIGHT, 27 | CONSTRAINT_HEIGHT_MAX, 28 | CONSTRAINT_HEIGHT_MIN, 29 | DOMAIN, 30 | ) 31 | from .models import Gender 32 | 33 | 34 | @callback 35 | def _get_options_schema( 36 | defaults: dict[str, Any] | MappingProxyType[str, Any], 37 | ) -> vol.Schema: 38 | """Return options schema.""" 39 | return vol.Schema( 40 | { 41 | vol.Required( 42 | CONF_HEIGHT, 43 | description=( 44 | {"suggested_value": defaults[CONF_HEIGHT]} 45 | if CONF_HEIGHT in defaults 46 | else None 47 | ), 48 | ): selector.NumberSelector( 49 | selector.NumberSelectorConfig( 50 | mode=selector.NumberSelectorMode.BOX, 51 | min=CONSTRAINT_HEIGHT_MIN, 52 | max=CONSTRAINT_HEIGHT_MAX, 53 | unit_of_measurement="cm", 54 | ) 55 | ), 56 | vol.Required( 57 | CONF_SENSOR_WEIGHT, 58 | description=( 59 | {"suggested_value": defaults[CONF_SENSOR_WEIGHT]} 60 | if CONF_SENSOR_WEIGHT in defaults 61 | else None 62 | ), 63 | ): selector.EntitySelector( 64 | selector.EntitySelectorConfig( 65 | domain=["sensor", "input_number", "number"] 66 | ) 67 | ), 68 | vol.Optional( 69 | CONF_SENSOR_IMPEDANCE, 70 | description=( 71 | {"suggested_value": defaults[CONF_SENSOR_IMPEDANCE]} 72 | if CONF_SENSOR_IMPEDANCE in defaults 73 | else None 74 | ), 75 | ): selector.EntitySelector( 76 | selector.EntitySelectorConfig( 77 | domain=["sensor", "input_number", "number"] 78 | ) 79 | ), 80 | vol.Optional( 81 | CONF_SENSOR_LAST_MEASUREMENT_TIME, 82 | description=( 83 | {"suggested_value": defaults[CONF_SENSOR_LAST_MEASUREMENT_TIME]} 84 | if CONF_SENSOR_LAST_MEASUREMENT_TIME in defaults 85 | else None 86 | ), 87 | ): selector.EntitySelector( 88 | selector.EntitySelectorConfig(domain=["sensor", "input_datetime"]) 89 | ), 90 | } 91 | ) 92 | 93 | 94 | class BodyMiScaleFlowHandler(ConfigFlow, domain=DOMAIN): 95 | """Config flow for bodymiscale.""" 96 | 97 | VERSION = 2 98 | 99 | def __init__(self) -> None: 100 | """Initialize BodyMiScaleFlowHandler.""" 101 | super().__init__() 102 | self._data: dict[str, str] = {} 103 | 104 | @staticmethod 105 | @callback 106 | def async_get_options_flow( 107 | config_entry: ConfigEntry, 108 | ) -> BodyMiScaleOptionsFlowHandler: 109 | """Get the options flow for this handler.""" 110 | return BodyMiScaleOptionsFlowHandler(config_entry) 111 | 112 | async def async_step_user( 113 | self, user_input: dict[str, Any] | None = None 114 | ) -> ConfigFlowResult: 115 | """Handle a flow initialized by the user.""" 116 | errors: dict[str, str] = {} 117 | if user_input is not None: 118 | try: 119 | cv.date(user_input[CONF_BIRTHDAY]) 120 | except vol.Invalid: 121 | errors[CONF_BIRTHDAY] = "invalid_date" 122 | 123 | if not errors: 124 | self._async_abort_entries_match({CONF_NAME: user_input[CONF_NAME]}) 125 | self._data = user_input 126 | return await self.async_step_options() 127 | 128 | user_input = user_input or {} 129 | return self.async_show_form( 130 | step_id="user", 131 | errors=errors, 132 | data_schema=vol.Schema( 133 | { 134 | vol.Required( 135 | CONF_NAME, default=user_input.get(CONF_NAME, vol.UNDEFINED) 136 | ): str, 137 | vol.Required( 138 | CONF_BIRTHDAY, 139 | default=user_input.get(CONF_BIRTHDAY, vol.UNDEFINED), 140 | ): selector.TextSelector( 141 | selector.TextSelectorConfig(type=selector.TextSelectorType.DATE) 142 | ), 143 | vol.Required( 144 | CONF_GENDER, default=user_input.get(CONF_GENDER, vol.UNDEFINED) 145 | ): vol.In({gender: gender.value for gender in Gender}), 146 | } 147 | ), 148 | ) 149 | 150 | async def async_step_options( 151 | self, user_input: dict[str, Any] | None = None 152 | ) -> ConfigFlowResult: 153 | """Handle step options.""" 154 | errors: dict[str, str] = {} 155 | 156 | if user_input is not None: 157 | if user_input[CONF_HEIGHT] > CONSTRAINT_HEIGHT_MAX: 158 | errors[CONF_HEIGHT] = "height_limit" 159 | elif user_input[CONF_HEIGHT] < CONSTRAINT_HEIGHT_MIN: 160 | errors[CONF_HEIGHT] = "height_low" 161 | 162 | return self.async_create_entry( 163 | title=self._data[CONF_NAME], 164 | data=self._data, 165 | options=user_input, 166 | ) 167 | 168 | user_input = user_input or {} 169 | return self.async_show_form( 170 | step_id="options", 171 | data_schema=_get_options_schema(user_input), 172 | errors=errors, 173 | ) 174 | 175 | 176 | class BodyMiScaleOptionsFlowHandler(OptionsFlow): 177 | """Handle Body mi scale options.""" 178 | 179 | def __init__(self, config_entry: ConfigEntry) -> None: 180 | """Initialize Body mi scale options flow.""" 181 | self._config_entry = config_entry 182 | 183 | async def async_step_init( 184 | self, user_input: dict[str, Any] | None = None 185 | ) -> ConfigFlowResult: 186 | """Manage Body mi scale options.""" 187 | 188 | if user_input is not None: 189 | return self.async_create_entry(data=user_input) 190 | 191 | # Convert MappingProxyType en dict 192 | user_input = dict(self._config_entry.options) 193 | 194 | return self.async_show_form( 195 | step_id="init", 196 | data_schema=_get_options_schema(user_input), 197 | ) 198 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/metrics/impedance.py: -------------------------------------------------------------------------------- 1 | """Metrics module, which require impedance and other metrics.""" 2 | 3 | from collections.abc import Mapping 4 | from datetime import datetime 5 | from typing import Any 6 | 7 | from homeassistant.helpers.typing import StateType 8 | 9 | from ..const import CONF_GENDER, CONF_HEIGHT, CONF_SCALE 10 | from ..models import Gender, Metric 11 | from ..util import check_value_constraints, to_float 12 | 13 | 14 | def get_lbm( 15 | config: Mapping[str, Any], metrics: Mapping[Metric, StateType | datetime] 16 | ) -> float: 17 | """Get LBM coefficient (with impedance).""" 18 | height = to_float(config.get(CONF_HEIGHT)) 19 | weight = to_float(metrics.get(Metric.WEIGHT)) 20 | impedance = to_float(metrics.get(Metric.IMPEDANCE)) 21 | age = to_float(metrics.get(Metric.AGE)) 22 | 23 | lbm = 0.0 24 | 25 | if ( 26 | height is not None 27 | and weight is not None 28 | and impedance is not None 29 | and age is not None 30 | ): 31 | lbm = (height * 9.058 / 100) * (height / 100) 32 | lbm += weight * 0.32 + 12.226 33 | lbm -= impedance * 0.0068 34 | lbm -= age * 0.0542 35 | 36 | return float(lbm) 37 | 38 | 39 | def get_fat_percentage( 40 | config: Mapping[str, Any], metrics: Mapping[Metric, StateType | datetime] 41 | ) -> float: 42 | """Calculate fat percentage.""" 43 | weight = to_float(metrics.get(Metric.WEIGHT)) 44 | lbm = to_float(metrics.get(Metric.LBM)) 45 | age = to_float(metrics.get(Metric.AGE)) 46 | height = to_float(config.get(CONF_HEIGHT)) 47 | gender = config.get(CONF_GENDER) 48 | 49 | # Return 0 if any required metric is missing 50 | if weight is None or lbm is None or age is None or height is None or gender is None: 51 | return 0.0 52 | 53 | coefficient = 1.0 54 | 55 | if gender == Gender.FEMALE: 56 | const = 9.25 if age <= 49 else 7.25 57 | if weight > 60: 58 | coefficient = 0.96 59 | elif weight < 50: 60 | coefficient = 1.02 61 | if height > 160 and (weight < 50 or weight > 60): 62 | coefficient *= 1.03 63 | else: 64 | const = 0.8 65 | if weight < 61: 66 | coefficient = 0.98 67 | 68 | fat_percentage = (1.0 - ((lbm - const) * coefficient / weight)) * 100 69 | 70 | # Cap fat_percentage at 75 71 | if fat_percentage > 63: 72 | fat_percentage = 75 73 | 74 | return check_value_constraints(fat_percentage, 5, 75) 75 | 76 | 77 | def get_water_percentage( 78 | _: Mapping[str, Any], metrics: Mapping[Metric, StateType | datetime] 79 | ) -> float: 80 | """Get water percentage.""" 81 | fat_percentage = to_float(metrics.get(Metric.FAT_PERCENTAGE)) or 0.0 82 | water_percentage = (100 - fat_percentage) * 0.7 83 | coefficient = 1.02 if water_percentage <= 50 else 0.98 84 | 85 | water_percentage *= coefficient 86 | if water_percentage >= 65: 87 | water_percentage = 75 88 | 89 | return check_value_constraints(water_percentage, 35, 75) 90 | 91 | 92 | def get_bone_mass( 93 | config: Mapping[str, Any], metrics: Mapping[Metric, StateType | datetime] 94 | ) -> float: 95 | """Get bone mass.""" 96 | lbm = to_float(metrics.get(Metric.LBM)) or 0.0 97 | base = 0.245691014 if config[CONF_GENDER] == Gender.FEMALE else 0.18016894 98 | 99 | bone_mass = (base - (lbm * 0.05158)) * -1 100 | 101 | if bone_mass > 2.2: 102 | bone_mass += 0.1 103 | else: 104 | bone_mass -= 0.1 105 | 106 | if config[CONF_GENDER] == Gender.FEMALE and bone_mass > 5.1: 107 | bone_mass = 8 108 | elif config[CONF_GENDER] == Gender.MALE and bone_mass > 5.2: 109 | bone_mass = 8 110 | 111 | return check_value_constraints(bone_mass, 0.5, 8) 112 | 113 | 114 | def get_muscle_mass( 115 | config: Mapping[str, Any], metrics: Mapping[Metric, StateType | datetime] 116 | ) -> float: 117 | """Get muscle mass.""" 118 | weight = to_float(metrics.get(Metric.WEIGHT)) or 0.0 119 | fat_percentage = to_float(metrics.get(Metric.FAT_PERCENTAGE)) or 0.0 120 | bone_mass = to_float(metrics.get(Metric.BONE_MASS)) or 0.0 121 | 122 | muscle_mass = weight - ((fat_percentage * 0.01) * weight) - bone_mass 123 | 124 | if config[CONF_GENDER] == Gender.FEMALE and muscle_mass >= 84: 125 | muscle_mass = 120 126 | elif config[CONF_GENDER] == Gender.MALE and muscle_mass >= 93.5: 127 | muscle_mass = 120 128 | 129 | return check_value_constraints(muscle_mass, 10, 120) 130 | 131 | 132 | def get_metabolic_age( 133 | config: Mapping[str, Any], metrics: Mapping[Metric, StateType | datetime] 134 | ) -> float: 135 | """Get metabolic age.""" 136 | height = to_float(config.get(CONF_HEIGHT)) 137 | weight = to_float(metrics.get(Metric.WEIGHT)) 138 | age = to_float(metrics.get(Metric.AGE)) 139 | impedance = to_float(metrics.get(Metric.IMPEDANCE)) 140 | 141 | metabolic_age = 15.0 142 | 143 | if ( 144 | height is not None 145 | and weight is not None 146 | and age is not None 147 | and impedance is not None 148 | ): 149 | if config[CONF_GENDER] == Gender.FEMALE: 150 | metabolic_age = ( 151 | (height * -1.1165) 152 | + (weight * 1.5784) 153 | + (age * 0.4615) 154 | + (impedance * 0.0415) 155 | + 83.2548 156 | ) 157 | else: 158 | metabolic_age = ( 159 | (height * -0.7471) 160 | + (weight * 0.9161) 161 | + (age * 0.4184) 162 | + (impedance * 0.0517) 163 | + 54.2267 164 | ) 165 | 166 | return check_value_constraints(metabolic_age, 15, 80) 167 | 168 | 169 | def get_protein_percentage( 170 | _: Mapping[str, Any], metrics: Mapping[Metric, StateType | datetime] 171 | ) -> float: 172 | """Get protein percentage.""" 173 | muscle_mass = to_float(metrics.get(Metric.MUSCLE_MASS)) or 0.0 174 | weight = to_float(metrics.get(Metric.WEIGHT)) or 0.0 175 | water_percentage = to_float(metrics.get(Metric.WATER_PERCENTAGE)) or 0.0 176 | 177 | if weight == 0: 178 | return 0.0 179 | 180 | protein_percentage = (muscle_mass / weight) * 100 - water_percentage 181 | return check_value_constraints(protein_percentage, 5, 32) 182 | 183 | 184 | def get_fat_mass_to_ideal_weight( 185 | config: Mapping[str, Any], metrics: Mapping[Metric, StateType | datetime] 186 | ) -> float: 187 | """Get missing mass to ideal weight.""" 188 | weight = to_float(metrics.get(Metric.WEIGHT)) or 0.0 189 | age = to_float(metrics.get(Metric.AGE)) or 0.0 190 | fat_percentage = to_float(metrics.get(Metric.FAT_PERCENTAGE)) or 0.0 191 | 192 | target_fat_pct = config[CONF_SCALE].get_fat_percentage(age)[2] 193 | fat_mass_to_ideal = weight * (target_fat_pct / 100) - weight * ( 194 | fat_percentage / 100 195 | ) 196 | 197 | return float(fat_mass_to_ideal) 198 | 199 | 200 | def get_body_type( 201 | config: Mapping[str, Any], metrics: Mapping[Metric, StateType | datetime] 202 | ) -> str: 203 | """Get body type.""" 204 | fat = to_float(metrics.get(Metric.FAT_PERCENTAGE)) or 0.0 205 | muscle = to_float(metrics.get(Metric.MUSCLE_MASS)) or 0.0 206 | age = to_float(metrics.get(Metric.AGE)) or 0.0 207 | scale = config[CONF_SCALE] 208 | 209 | if fat > scale.get_fat_percentage(age)[2]: 210 | factor = 0 211 | elif fat < scale.get_fat_percentage(age)[1]: 212 | factor = 2 213 | else: 214 | factor = 1 215 | 216 | body_type = 1 + (factor * 3) 217 | if muscle > scale.muscle_mass[1]: 218 | body_type = 2 + (factor * 3) 219 | elif muscle < scale.muscle_mass[0]: 220 | body_type = factor * 3 221 | 222 | return [ 223 | "obese", 224 | "overweight", 225 | "thick_set", 226 | "lack_exercise", 227 | "balanced", 228 | "balanced_muscular", 229 | "skinny", 230 | "balanced_skinny", 231 | "skinny_muscular", 232 | ][body_type] 233 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/metrics/body_score.py: -------------------------------------------------------------------------------- 1 | """Body score module.""" 2 | 3 | from collections import namedtuple 4 | from collections.abc import Mapping 5 | from datetime import datetime 6 | from typing import Any, cast 7 | 8 | from homeassistant.helpers.typing import StateType 9 | 10 | from ..const import CONF_GENDER, CONF_HEIGHT, CONF_SCALE 11 | from ..models import Gender, Metric 12 | 13 | 14 | def _get_malus( 15 | data: float, 16 | min_data: float, 17 | max_data: float, 18 | max_malus: int | float, 19 | min_malus: int | float, 20 | ) -> float: 21 | """Calculate malus based on data and predefined ranges (original logic).""" 22 | result = ((data - max_data) / (min_data - max_data)) * float(max_malus - min_malus) 23 | if result >= 0.0: 24 | return result 25 | return 0.0 26 | 27 | 28 | def _calculate_bmi_deduct_score( 29 | config: Mapping[str, Any], metrics: Mapping[Metric, StateType | datetime] 30 | ) -> float: 31 | """Calculate BMI deduct score.""" 32 | bmi_very_low = 14.0 33 | bmi_low = 15.0 34 | bmi_normal = 18.5 35 | bmi_overweight = 28.0 36 | bmi_obese = 32.0 37 | 38 | if config[CONF_HEIGHT] < 90: 39 | return 0.0 40 | 41 | bmi = cast(float, metrics[Metric.BMI]) 42 | age = cast(int, metrics[Metric.AGE]) 43 | fat_percentage = cast(float, metrics[Metric.FAT_PERCENTAGE]) 44 | fat_scale = config[CONF_SCALE].get_fat_percentage(age) 45 | 46 | if bmi <= bmi_very_low: 47 | return 30.0 48 | 49 | if (fat_percentage < fat_scale[2]) and ( 50 | (bmi >= bmi_normal and age >= 18) or (bmi >= bmi_low and age < 18) 51 | ): 52 | return 0.0 53 | 54 | if bmi < bmi_low: 55 | return _get_malus(bmi, bmi_very_low, bmi_low, 30, 15) + 15.0 56 | if bmi < bmi_normal and age >= 18: 57 | return _get_malus(bmi, 15.0, 18.5, 15, 5) + 5.0 58 | 59 | if fat_percentage >= fat_scale[2]: 60 | if bmi >= bmi_obese: 61 | return 10.0 62 | if bmi > bmi_overweight: 63 | return _get_malus(bmi, 28.0, 25.0, 5, 10) + 5.0 64 | 65 | return 0.0 66 | 67 | 68 | def _calculate_body_fat_deduct_score( 69 | config: Mapping[str, Any], metrics: Mapping[Metric, StateType | datetime] 70 | ) -> float: 71 | """Calculate body fat deduct score.""" 72 | fat_percentage = cast(float, metrics[Metric.FAT_PERCENTAGE]) 73 | age = cast(int, metrics[Metric.AGE]) 74 | gender = config[CONF_GENDER] 75 | scale = config[CONF_SCALE].get_fat_percentage(age) 76 | 77 | best_fat_level = scale[2] - 3.0 if gender == Gender.MALE else scale[2] - 2.0 78 | 79 | if scale[0] <= fat_percentage < best_fat_level: 80 | return 0.0 81 | if fat_percentage >= scale[3]: 82 | return 20.0 83 | 84 | if fat_percentage < scale[3]: 85 | return _get_malus(fat_percentage, scale[3], scale[2], 20, 10) + 10.0 86 | 87 | if fat_percentage <= scale[2]: 88 | return _get_malus(fat_percentage, scale[2], best_fat_level, 3, 9) + 3.0 89 | 90 | if fat_percentage < scale[0]: 91 | return _get_malus(fat_percentage, 1.0, scale[0], 3, 10) + 3.0 92 | 93 | return 0.0 94 | 95 | 96 | def _calculate_common_deduct_score( 97 | min_value: float, max_value: float, value: float 98 | ) -> float: 99 | """Calculate common deduct score based on min/max values.""" 100 | if value >= max_value: 101 | return 0.0 102 | if value < min_value: 103 | return 10.0 104 | return _get_malus(value, min_value, max_value, 10, 5) + 5.0 105 | 106 | 107 | def _calculate_muscle_deduct_score( 108 | config: Mapping[str, Any], muscle_mass: float 109 | ) -> float: 110 | """Calculate muscle mass deduct score.""" 111 | scale = config[CONF_SCALE].muscle_mass 112 | return _calculate_common_deduct_score(scale[0] - 5.0, scale[0], muscle_mass) 113 | 114 | 115 | def _calculate_water_deduct_score( 116 | config: Mapping[str, Any], water_percentage: float 117 | ) -> float: 118 | """Calculate water percentage deduct score.""" 119 | water_percentage_normal = 55.0 if config[CONF_GENDER] == Gender.MALE else 45.0 120 | return _calculate_common_deduct_score( 121 | water_percentage_normal - 5.0, water_percentage_normal, water_percentage 122 | ) 123 | 124 | 125 | def _calculate_bone_deduct_score( 126 | config: Mapping[str, Any], metrics: Mapping[Metric, StateType | datetime] 127 | ) -> float: 128 | """Calculate bone mass deduct score.""" 129 | BoneMassEntry = namedtuple("BoneMassEntry", ["min_weight", "bone_mass"]) 130 | 131 | if config[CONF_GENDER] == Gender.MALE: 132 | entries = [ 133 | BoneMassEntry(75, 2.0), 134 | BoneMassEntry(60, 1.9), 135 | BoneMassEntry(0, 1.6), 136 | ] 137 | else: 138 | entries = [ 139 | BoneMassEntry(60, 1.8), 140 | BoneMassEntry(45, 1.5), 141 | BoneMassEntry(0, 1.3), 142 | ] 143 | 144 | weight = cast(float, metrics[Metric.WEIGHT]) 145 | bone_mass = cast(float, metrics[Metric.BONE_MASS]) 146 | expected_bone_mass = entries[-1].bone_mass 147 | for entry in entries: 148 | if weight >= entry.min_weight: 149 | expected_bone_mass = entry.bone_mass 150 | break 151 | 152 | return _calculate_common_deduct_score( 153 | expected_bone_mass - 0.3, expected_bone_mass, bone_mass 154 | ) 155 | 156 | 157 | def _calculate_body_visceral_deduct_score(visceral_fat: float) -> float: 158 | """Calculate visceral fat deduct score.""" 159 | max_data = 15.0 160 | min_data = 10.0 161 | 162 | if visceral_fat < min_data: 163 | return 0.0 164 | if visceral_fat >= max_data: 165 | return 15.0 166 | return _get_malus(visceral_fat, max_data, min_data, max_data, min_data) + 10.0 167 | 168 | 169 | def _calculate_basal_metabolism_deduct_score( 170 | config: Mapping[str, Any], metrics: Mapping[Metric, StateType | datetime] 171 | ) -> float: 172 | """Calculate basal metabolism deduct score.""" 173 | gender = config[CONF_GENDER] 174 | age = cast(int, metrics[Metric.AGE]) 175 | weight = cast(float, metrics[Metric.WEIGHT]) 176 | bmr = cast(float, metrics[Metric.BMR]) 177 | 178 | coefficients = { 179 | Gender.MALE: {30: 21.6, 50: 20.07, 100: 19.35}, 180 | Gender.FEMALE: {30: 21.24, 50: 19.53, 100: 18.63}, 181 | } 182 | 183 | normal_bmr = 20.0 184 | for c_age, coefficient in coefficients[gender].items(): 185 | if age < c_age: 186 | normal_bmr = weight * coefficient 187 | break 188 | 189 | if bmr >= normal_bmr: 190 | return 0.0 191 | if bmr <= normal_bmr - 300: 192 | return 6.0 193 | return _get_malus(bmr, normal_bmr - 300, normal_bmr, 6, 3) + 5.0 194 | 195 | 196 | def _calculate_protein_deduct_score(protein_percentage: float) -> float: 197 | """Calculate protein deduct score.""" 198 | if protein_percentage > 17.0: 199 | return 0.0 200 | if protein_percentage < 10.0: 201 | return 10.0 202 | if protein_percentage <= 16.0: 203 | return _get_malus(protein_percentage, 10.0, 16.0, 10, 5) + 5.0 204 | if protein_percentage <= 17.0: 205 | return _get_malus(protein_percentage, 16.0, 17.0, 5, 3) + 3.0 206 | return 0.0 207 | 208 | 209 | def get_body_score( 210 | config: Mapping[str, Any], metrics: Mapping[Metric, StateType | datetime] 211 | ) -> float: 212 | """Calculate the body score.""" 213 | score = 100.0 214 | score -= _calculate_bmi_deduct_score(config, metrics) 215 | score -= _calculate_body_fat_deduct_score(config, metrics) 216 | score -= _calculate_muscle_deduct_score( 217 | config, cast(float, metrics[Metric.MUSCLE_MASS]) 218 | ) 219 | score -= _calculate_water_deduct_score( 220 | config, cast(float, metrics[Metric.WATER_PERCENTAGE]) 221 | ) 222 | score -= _calculate_body_visceral_deduct_score( 223 | cast(float, metrics[Metric.VISCERAL_FAT]) 224 | ) 225 | score -= _calculate_bone_deduct_score(config, metrics) 226 | score -= _calculate_basal_metabolism_deduct_score(config, metrics) 227 | score -= _calculate_protein_deduct_score( 228 | cast(float, metrics[Metric.PROTEIN_PERCENTAGE]) 229 | ) 230 | 231 | return max(0.0, score) 232 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/__init__.py: -------------------------------------------------------------------------------- 1 | """Support for bodymiscale.""" 2 | 3 | import asyncio 4 | import logging 5 | from collections.abc import MutableMapping 6 | from datetime import datetime 7 | from functools import partial 8 | from types import MappingProxyType 9 | from typing import Any 10 | 11 | import homeassistant.helpers.config_validation as cv 12 | import voluptuous as vol 13 | from awesomeversion import AwesomeVersion 14 | from homeassistant.config_entries import ConfigEntry 15 | from homeassistant.const import CONF_NAME, CONF_SENSORS, STATE_OK, STATE_PROBLEM 16 | from homeassistant.const import __version__ as HA_VERSION 17 | from homeassistant.core import HomeAssistant 18 | from homeassistant.helpers.entity import EntityDescription 19 | from homeassistant.helpers.entity_component import EntityComponent 20 | from homeassistant.helpers.typing import StateType 21 | 22 | from custom_components.bodymiscale.metrics import BodyScaleMetricsHandler 23 | from custom_components.bodymiscale.models import Metric 24 | from custom_components.bodymiscale.util import get_age, get_bmi_label, get_ideal_weight 25 | 26 | from .const import ( 27 | ATTR_AGE, 28 | ATTR_BMILABEL, 29 | ATTR_FATMASSTOGAIN, 30 | ATTR_FATMASSTOLOSE, 31 | ATTR_IDEAL, 32 | ATTR_PROBLEM, 33 | COMPONENT, 34 | CONF_BIRTHDAY, 35 | CONF_GENDER, 36 | CONF_HEIGHT, 37 | CONF_SENSOR_IMPEDANCE, 38 | CONF_SENSOR_LAST_MEASUREMENT_TIME, 39 | CONF_SENSOR_WEIGHT, 40 | DOMAIN, 41 | HANDLERS, 42 | MIN_REQUIRED_HA_VERSION, 43 | PLATFORMS, 44 | PROBLEM_NONE, 45 | STARTUP_MESSAGE, 46 | UPDATE_DELAY, 47 | ) 48 | from .entity import BodyScaleBaseEntity 49 | 50 | _LOGGER = logging.getLogger(__name__) 51 | 52 | SCHEMA_SENSORS = vol.Schema( 53 | { 54 | vol.Required(CONF_SENSOR_WEIGHT): cv.entity_id, 55 | vol.Optional(CONF_SENSOR_IMPEDANCE): cv.entity_id, 56 | vol.Optional(CONF_SENSOR_LAST_MEASUREMENT_TIME): cv.entity_id, 57 | } 58 | ) 59 | 60 | BODYMISCALE_SCHEMA = vol.Schema( 61 | { 62 | vol.Required(CONF_SENSORS): vol.Schema(SCHEMA_SENSORS), 63 | vol.Required(CONF_HEIGHT): cv.positive_int, 64 | vol.Required("born"): cv.string, 65 | vol.Required(CONF_GENDER): cv.string, 66 | }, 67 | extra=vol.ALLOW_EXTRA, 68 | ) 69 | 70 | CONFIG_SCHEMA = vol.Schema( 71 | {DOMAIN: {cv.string: BODYMISCALE_SCHEMA}}, extra=vol.ALLOW_EXTRA 72 | ) 73 | 74 | 75 | def is_ha_supported() -> bool: 76 | """Return True, if current HA version is supported.""" 77 | if AwesomeVersion(HA_VERSION) >= MIN_REQUIRED_HA_VERSION: 78 | return True 79 | 80 | _LOGGER.error( 81 | 'Unsupported HA version! Please upgrade home assistant at least to "%s"', 82 | MIN_REQUIRED_HA_VERSION, 83 | ) 84 | return False 85 | 86 | 87 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 88 | """Set up component via UI.""" 89 | if not is_ha_supported(): 90 | return False 91 | 92 | if hass.data.get(DOMAIN) is None: 93 | hass.data.setdefault( 94 | DOMAIN, 95 | { 96 | COMPONENT: EntityComponent(_LOGGER, DOMAIN, hass), 97 | HANDLERS: {}, 98 | }, 99 | ) 100 | _LOGGER.info(STARTUP_MESSAGE) 101 | 102 | handler = hass.data[DOMAIN][HANDLERS][entry.entry_id] = BodyScaleMetricsHandler( 103 | hass, {**entry.data, **entry.options}, entry.entry_id 104 | ) 105 | 106 | component: EntityComponent = hass.data[DOMAIN][COMPONENT] 107 | await component.async_add_entities([Bodymiscale(handler)]) 108 | 109 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 110 | # Reload entry when its updated. 111 | entry.async_on_unload(entry.add_update_listener(async_reload_entry)) 112 | 113 | return True 114 | 115 | 116 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 117 | """Unload a config entry.""" 118 | unload_ok: bool = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 119 | 120 | if unload_ok: 121 | component: EntityComponent = hass.data[DOMAIN][COMPONENT] 122 | await component.async_prepare_reload() 123 | 124 | del hass.data[DOMAIN][HANDLERS][entry.entry_id] 125 | if len(hass.data[DOMAIN][HANDLERS]) == 0: 126 | hass.data.pop(DOMAIN) 127 | 128 | return unload_ok 129 | 130 | 131 | async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: 132 | """Reload the config entry when it changed.""" 133 | await hass.config_entries.async_reload(entry.entry_id) 134 | 135 | 136 | async def async_migrate_entry(_: HomeAssistant, config_entry: ConfigEntry) -> bool: 137 | """Migrate old entry.""" 138 | _LOGGER.debug("Migrating from version %d", config_entry.version) 139 | 140 | if config_entry.version == 1: 141 | data = {**config_entry.data} 142 | options = { 143 | CONF_HEIGHT: data.pop(CONF_HEIGHT), 144 | CONF_SENSOR_WEIGHT: data.pop(CONF_SENSOR_WEIGHT), 145 | } 146 | if CONF_SENSOR_IMPEDANCE in data: 147 | options[CONF_SENSOR_IMPEDANCE] = data.pop(CONF_SENSOR_IMPEDANCE) 148 | 149 | if config_entry.options: 150 | options.update(config_entry.options) 151 | options.pop(CONF_NAME) 152 | options.pop(CONF_BIRTHDAY) 153 | options.pop(CONF_GENDER) 154 | 155 | config_entry.data = MappingProxyType(data) 156 | config_entry.options = MappingProxyType(options) 157 | 158 | config_entry.version = 2 159 | 160 | _LOGGER.info("Migration to version %d successful", config_entry.version) 161 | return True 162 | 163 | 164 | class Bodymiscale(BodyScaleBaseEntity): 165 | """Bodymiscale the well-being of a body. 166 | 167 | It also checks the measurements against weight, height, age, 168 | gender and impedance (if configured). 169 | """ 170 | 171 | def __init__(self, handler: BodyScaleMetricsHandler): 172 | """Initialize the Bodymiscale component.""" 173 | super().__init__( 174 | handler, 175 | EntityDescription(key="bodymiscale", name=None, icon="mdi:human"), 176 | ) 177 | self._timer_handle: asyncio.TimerHandle | None = None 178 | self._available_metrics: MutableMapping[str, StateType | datetime] = {} 179 | 180 | async def async_added_to_hass(self) -> None: 181 | """After being added to hass.""" 182 | await super().async_added_to_hass() 183 | 184 | loop = asyncio.get_event_loop() 185 | 186 | def on_value(value: StateType | datetime, *, metric: Metric) -> None: 187 | if metric == Metric.STATUS: 188 | self._attr_state = STATE_OK if value == PROBLEM_NONE else STATE_PROBLEM 189 | self._available_metrics[ATTR_PROBLEM] = value 190 | else: 191 | self._available_metrics[metric.value] = value 192 | 193 | if self._timer_handle is not None: 194 | self._timer_handle.cancel() 195 | self._timer_handle = loop.call_later( 196 | UPDATE_DELAY, self.async_write_ha_state 197 | ) 198 | 199 | remove_subscriptions = [] 200 | for metric in Metric: 201 | remove_subscriptions.append( 202 | self._handler.subscribe(metric, partial(on_value, metric=metric)) 203 | ) 204 | 205 | def on_remove() -> None: 206 | for subscription in remove_subscriptions: 207 | subscription() 208 | 209 | self.async_on_remove(on_remove) 210 | 211 | @property 212 | def state_attributes(self) -> dict[str, Any]: 213 | """Return the attributes of the entity.""" 214 | attrib = { 215 | CONF_HEIGHT: self._handler.config[CONF_HEIGHT], 216 | CONF_GENDER: self._handler.config[CONF_GENDER].value, 217 | ATTR_IDEAL: get_ideal_weight(self._handler.config), 218 | ATTR_AGE: get_age(self._handler.config[CONF_BIRTHDAY]), 219 | **self._available_metrics, 220 | } 221 | 222 | if Metric.BMI.value in attrib: 223 | attrib[ATTR_BMILABEL] = get_bmi_label(attrib[Metric.BMI.value]) 224 | 225 | if Metric.FAT_MASS_2_IDEAL_WEIGHT.value in attrib: 226 | value = attrib.pop(Metric.FAT_MASS_2_IDEAL_WEIGHT.value) 227 | 228 | if value < 0: 229 | attrib[ATTR_FATMASSTOLOSE] = value * -1 230 | else: 231 | attrib[ATTR_FATMASSTOGAIN] = value 232 | 233 | return attrib 234 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | 6 | 7 | ## 2025.9.0 8 | 9 | - **Added:** Danish language support (thank you @Milfeldt). 10 | - **Fixed:** Fixed pylint warnings regarding BaseException and Exception in the configuration file. 11 | - **Fixed:** Automatic code formatting with Black and import reorganization with isort. 12 | - **Fixed:** Fixed mypy errors related to incorrect types for state and datetime. 13 | - **Changed (Development):** Updated the development environment to use **Python 3.13**. 14 | - **Changed:** Automatic conversion of sensor values to float with exception handling. 15 | - **Removed:** Unnecessary suppressions in pylintrc. 16 | - **Removed:** Obsolete option abstract-class-little-used removed from Pylint configuration. 17 | 18 | ## 2025.4.0 19 | 20 | - **Fix:** Ensure Bodymiscale entity attributes (including metrics and last measurement time) persist correctly. The internal `TTLCache` used for storing entity attributes has been replaced with a standard dictionary to prevent data loss when only the last measurement time sensor updates. 21 | - **Refactor:** The `TTLCache` in `BodyScaleMetricsHandler` (in `metrics.py`) is now used solely for internal optimization of metric calculations and distribution. 22 | - **Added:** Ability to integrate your latest weight sensor. This integration creates a new sensor `last_measurement_time` and adds an attribute to the `Bodymiscale` component to display the last measurement time. 23 | - **Updated:** Polish translation (thank you @witold-gren). 24 | - **Added:** Slovakia language support (thank you @milandzuris). 25 | - **Improved:** README presentation for better readability. 26 | 27 | ## 2024.6.0 28 | 29 | - **Removed** The `group.py` file, as its functionality is no longer needed. 30 | 31 | ## 2024.1.3 32 | 33 | - **Fixed:** Corrected an issue with the minimal Home Assistant version check. 34 | 35 | ## 2024.1.2 36 | 37 | - **Fixed:** Another correction to the minimal Home Assistant version check. 38 | 39 | ## 2024.1.1 40 | 41 | - **Changed:** Version number patch. 42 | 43 | ## 2024.1.0 44 | 45 | - **Fixed:** [#218](https://github.com/dckiller51/bodymiscale/issues/218) - Set `state_class` as a class variable after installing Core 2024.1 beta (@edenhaus). 46 | 47 | ## 2023.11.2 48 | 49 | - **Fixed:** Corrected the calculation issue when the main weight sensor is in pounds (lb). 50 | - **Added:** Device class "weight" for weight, muscle mass, and bone mass sensors (thanks @5high, @impankratov). 51 | - **Added:** Native unit of measurement "KILOGRAMS" for weight, muscle mass, and bone mass sensors. You can change the measurement units directly in the sensor. The conversion is automatic and will not impact the results. 52 | - **Added:** Native unit of measurement "kcal" for the basal metabolism sensor. 53 | - **Added:** Native unit of measurement "PERCENTAGE" for body fat, protein, and water sensors. 54 | - **Added:** Display precision of 0 for the following sensors to display integer values: - Basal metabolism - Visceral fat - Metabolic age - Body score 55 | 56 | ## 2023.11.1 57 | 58 | - **Added:** Traditional Chinese language support (thank you @yauyauwind). 59 | 60 | ## 2023.11.0 61 | 62 | - **Changed:** The minimum Home Assistant version is now 2023.9.0. 63 | - **Added:** Simple Chinese language support (thank you @5high). 64 | - **Changed:** Version number format to calendar versioning (YYYY.MM.Patch). 65 | 66 | ## v3.1.2 67 | 68 | - **Fixed:** [#27](https://github.com/dckiller51/bodymiscale/issues/27) and [#181](https://github.com/dckiller51/bodymiscale/issues/181) - Resolved the visceral fat result issue. 69 | 70 | ## v3.1.1 71 | 72 | - **Changed:** Updated state body type and BMI label for translation. 73 | 74 | ## v3.1.0 75 | 76 | - **Changed:** Updated state problems, body type, and BMI label for translation. 77 | - **Updated:** Translations for DE, EN, ES, FR, IT, PL, PT-BR, RO, and RU. 78 | - **Added:** Height limit low validation (#122). 79 | - **Fixed:** Naming inconsistencies (#191). 80 | 81 | ## v3.0.9 82 | 83 | - **Changed:** Moved DeviceInfo from entity to device registry. 84 | - **Changed:** Used shorthand attributes. 85 | - **Added:** Support for input type "number" in addition to "sensor" and "input_number". 86 | 87 | ## v3.0.8 88 | 89 | - **Fixed:** [#173](https://github.com/dckiller51/bodymiscale/issues/173) - Resolved naming issues after installing Core 2023.7.0 (@edenhaus). 90 | - **Added:** Logger to manifest (@edenhaus). 91 | 92 | ## v3.0.7 93 | 94 | - **Changed:** Removed sensor body type from sensor, but it is still available as an attribute in the component. 95 | - **Changed:** Refactored `async_setup_platforms` to `async_forward_entry_setups`. 96 | - **Fixed:** Spanish translation (thank you @Nahuel-BM). 97 | 98 | ## v3.0.6 99 | 100 | - **Added:** Spanish language support (thank you @Xesquy). 101 | 102 | ## v3.0.5 103 | 104 | - **Changed:** Moved the `get_age` function to utils (thank you @Gerto). 105 | - **Added:** `ATTR_AGE` to `state_attributes` (thank you @Gerto). 106 | 107 | ## v3.0.4 108 | 109 | - **Added:** Support for input type "input_number" in addition to "sensor" (thank you @erannave). 110 | 111 | ## v3.0.3 112 | 113 | - **Added:** Russian language support (thank you @glebsterx). 114 | 115 | ## v3.0.2 116 | 117 | - **Added:** Romanian language support (thank you @18rrs). 118 | 119 | ## v3.0.1 120 | 121 | - **Fixed:** [#104](https://github.com/dckiller51/bodymiscale/issues/104) - Subscribe to the correct handler. 122 | - **Added:** Italian language support (thank you @mansellrace). 123 | - **Added:** Polish language support (thank you @LukaszP2). 124 | 125 | ## v3.0.0 126 | 127 | - **Changed:** Created a sensor for each attribute (@edenhaus). 128 | - **Updated:** pt-BR translation (@dckiller51). 129 | - **Updated:** FR translation (@dckiller51). 130 | - **Updated:** Pylint from 2.13.9 to 2.14.3 (@dependabot). 131 | - **Updated:** actions/setup-python from 3 to 4 (@dependabot). 132 | - **Updated:** Mypy from 0.960 to 0.961 (@dependabot). 133 | - **Removed:** YAML support and fixed config flow options (@edenhaus). 134 | - **Updated:** pre-commit from 2.18.1 to 2.19.0 (@dependabot). 135 | - **Updated:** github/codeql-action from 1 to 2 (@dependabot). 136 | 137 | ## v2.1.1 138 | 139 | - **Fixed:** Error for iOS users where the birthday selection was not displayed. 140 | - **Fixed:** Error in selecting a date lower than 1970. 141 | - **Updated:** FR translation. 142 | 143 | ## v2.1.0 144 | 145 | - **Added:** Config flow (thank you @edenhaus). YAML file is no longer used. You can now easily add users via the Home Assistant UI (Settings -> Devices & Services -> Add -> Bodymiscale). 146 | 147 | ## v2.0.0 148 | 149 | Major update by @edenhaus, improving code quality and enabling development in a devcontainer. 150 | 151 | - **Added:** Code quality tools (pre-commit). 152 | - **Removed** Unused code (e.g., Holtek). 153 | - **Used:** `@cached_property` to optimize calculations. 154 | - **Adjusted:** Names and code to Python coding styles. 155 | - **Updated:** CI actions. 156 | - **Added:** Devcontainer for easier development. 157 | 158 | ## v1.1.5 159 | 160 | Fixed:\*\* Convert weight from lbs to kgs if your scale is set to this unit (thank you @rale). 161 | 162 | - **Added:** Portuguese Brazilian language support (thank you @hudsonbrendon). 163 | 164 | ## v1.1.4 165 | 166 | - **Updated:** `get_age` function to correctly calculate age from DOB (thank you @borpin). 167 | - **Used:** Assignment expressions. 168 | 169 | ## v1.1.3 170 | 171 | - **Updated:** README to indicate default integration availability in HACS. 172 | - **Updated:** `iot_class` (thank you @edenhaus). 173 | 174 | ## v1.1.2 175 | 176 | - **Updated:** For default integration in HACS. 177 | 178 | ## v1.1.1 179 | 180 | Fixed:\*\* Startup errors (thank you @stefangries). 181 | 182 | ## v1.1.0 183 | 184 | - **Added:** Body score (thank you @alinelena). 185 | 186 | ## v1.0.0 187 | 188 | - **Updated:** For the "181B" model: display the minimum score if the impedance sensor is unavailable. 189 | 190 | ## v0.0.8 191 | 192 | - **Fixed:** Spelling error: "Lack-exerscise" to "Lack-exercise". 193 | 194 | ## v0.0.7 195 | 196 | - **Updated:** Decimal handling (@typxxi). 197 | - **Updated:** README (@typxxi). 198 | 199 | ## v0.0.6 200 | 201 | - **Changed:** Attribute names to snake_case format (thanks to Pavel Popov). 202 | 203 | ## v0.0.5 204 | 205 | - **Changed:** Renamed `HomeAssistantType` to `HomeAssistant` for integrations. 206 | 207 | ## v0.0.4 208 | 209 | - **Fixed:** Startup error. 210 | - **Updated:** README (@Ernst79). 211 | 212 | ## v0.0.3 213 | 214 | Removed: Units for future custom card compatibility. 215 | 216 | ## v0.0.2 217 | 218 | Implemented: Calculations (thanks to lolouk44). 219 | 220 | ## v0.0.1 221 | 222 | Initial release. 223 | -------------------------------------------------------------------------------- /custom_components/bodymiscale/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor module.""" 2 | 3 | import logging 4 | from collections.abc import Callable, Mapping 5 | from datetime import datetime 6 | from typing import Any 7 | 8 | from homeassistant.components.sensor import ( 9 | SensorDeviceClass, 10 | SensorEntity, 11 | SensorEntityDescription, 12 | SensorStateClass, 13 | ) 14 | from homeassistant.config_entries import ConfigEntry 15 | from homeassistant.const import PERCENTAGE, UnitOfMass 16 | from homeassistant.core import HomeAssistant 17 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 18 | from homeassistant.helpers.typing import StateType 19 | 20 | from .const import ( 21 | ATTR_BMI, 22 | ATTR_BMILABEL, 23 | ATTR_BMR, 24 | ATTR_BODY_SCORE, 25 | ATTR_BONES, 26 | ATTR_FAT, 27 | ATTR_IDEAL, 28 | ATTR_LBM, 29 | ATTR_METABOLIC, 30 | ATTR_MUSCLE, 31 | ATTR_PROTEIN, 32 | ATTR_VISCERAL, 33 | ATTR_WATER, 34 | CONF_SENSOR_IMPEDANCE, 35 | CONF_SENSOR_LAST_MEASUREMENT_TIME, 36 | CONF_SENSOR_WEIGHT, 37 | DOMAIN, 38 | HANDLERS, 39 | ) 40 | from .entity import BodyScaleBaseEntity 41 | from .metrics import BodyScaleMetricsHandler 42 | from .models import Metric 43 | from .util import get_bmi_label, get_ideal_weight 44 | 45 | _LOGGER = logging.getLogger(__name__) 46 | 47 | 48 | async def async_setup_entry( 49 | hass: HomeAssistant, 50 | config_entry: ConfigEntry, 51 | async_add_entities: AddEntitiesCallback, 52 | ) -> None: 53 | """Add entities for passed config_entry in HA.""" 54 | handler: BodyScaleMetricsHandler = hass.data[DOMAIN][HANDLERS][ 55 | config_entry.entry_id 56 | ] 57 | 58 | new_sensors = [ 59 | BodyScaleSensor( 60 | handler, 61 | SensorEntityDescription( 62 | key=ATTR_BMI, 63 | translation_key="bmi", 64 | icon="mdi:human", 65 | state_class=SensorStateClass.MEASUREMENT, 66 | ), 67 | Metric.BMI, 68 | lambda state, _: { 69 | ATTR_BMILABEL: ( 70 | get_bmi_label(float(state)) 71 | if isinstance(state, (int, float)) 72 | else None 73 | ) 74 | }, 75 | ), 76 | BodyScaleSensor( 77 | handler, 78 | SensorEntityDescription( 79 | key=ATTR_BMR, 80 | translation_key="basal_metabolism", 81 | suggested_display_precision=0, 82 | native_unit_of_measurement="kcal", 83 | state_class=SensorStateClass.MEASUREMENT, 84 | ), 85 | Metric.BMR, 86 | ), 87 | BodyScaleSensor( 88 | handler, 89 | SensorEntityDescription( 90 | key=ATTR_VISCERAL, 91 | translation_key="visceral_fat", 92 | suggested_display_precision=0, 93 | state_class=SensorStateClass.MEASUREMENT, 94 | ), 95 | Metric.VISCERAL_FAT, 96 | ), 97 | BodyScaleSensor( 98 | handler, 99 | SensorEntityDescription( 100 | key=CONF_SENSOR_WEIGHT, 101 | translation_key="weight", 102 | native_unit_of_measurement=UnitOfMass.KILOGRAMS, 103 | device_class=SensorDeviceClass.WEIGHT, 104 | state_class=SensorStateClass.MEASUREMENT, 105 | ), 106 | Metric.WEIGHT, 107 | lambda _, config: {ATTR_IDEAL: get_ideal_weight(config)}, 108 | ), 109 | ] 110 | 111 | if CONF_SENSOR_LAST_MEASUREMENT_TIME in handler.config: 112 | new_sensors.append( 113 | BodyScaleSensor( 114 | handler, 115 | SensorEntityDescription( 116 | key=CONF_SENSOR_LAST_MEASUREMENT_TIME, 117 | translation_key="last_measurement_time", 118 | device_class=SensorDeviceClass.TIMESTAMP, 119 | ), 120 | Metric.LAST_MEASUREMENT_TIME, 121 | ) 122 | ) 123 | 124 | if CONF_SENSOR_IMPEDANCE in handler.config: 125 | new_sensors.extend( 126 | [ 127 | BodyScaleSensor( 128 | handler, 129 | SensorEntityDescription( 130 | key=ATTR_LBM, 131 | translation_key="lean_body_mass", 132 | native_unit_of_measurement=UnitOfMass.KILOGRAMS, 133 | device_class=SensorDeviceClass.WEIGHT, 134 | state_class=SensorStateClass.MEASUREMENT, 135 | ), 136 | Metric.LBM, 137 | ), 138 | BodyScaleSensor( 139 | handler, 140 | SensorEntityDescription( 141 | key=ATTR_FAT, 142 | translation_key="body_fat", 143 | native_unit_of_measurement=PERCENTAGE, 144 | state_class=SensorStateClass.MEASUREMENT, 145 | ), 146 | Metric.FAT_PERCENTAGE, 147 | ), 148 | BodyScaleSensor( 149 | handler, 150 | SensorEntityDescription( 151 | key=ATTR_PROTEIN, 152 | translation_key="protein", 153 | native_unit_of_measurement=PERCENTAGE, 154 | state_class=SensorStateClass.MEASUREMENT, 155 | ), 156 | Metric.PROTEIN_PERCENTAGE, 157 | ), 158 | BodyScaleSensor( 159 | handler, 160 | SensorEntityDescription( 161 | key=ATTR_WATER, 162 | translation_key="water", 163 | icon="mdi:water-percent", 164 | native_unit_of_measurement=PERCENTAGE, 165 | state_class=SensorStateClass.MEASUREMENT, 166 | ), 167 | Metric.WATER_PERCENTAGE, 168 | ), 169 | BodyScaleSensor( 170 | handler, 171 | SensorEntityDescription( 172 | key=ATTR_BONES, 173 | translation_key="bone_mass", 174 | native_unit_of_measurement=UnitOfMass.KILOGRAMS, 175 | device_class=SensorDeviceClass.WEIGHT, 176 | state_class=SensorStateClass.MEASUREMENT, 177 | ), 178 | Metric.BONE_MASS, 179 | ), 180 | BodyScaleSensor( 181 | handler, 182 | SensorEntityDescription( 183 | key=ATTR_MUSCLE, 184 | translation_key="muscle_mass", 185 | native_unit_of_measurement=UnitOfMass.KILOGRAMS, 186 | device_class=SensorDeviceClass.WEIGHT, 187 | state_class=SensorStateClass.MEASUREMENT, 188 | ), 189 | Metric.MUSCLE_MASS, 190 | ), 191 | BodyScaleSensor( 192 | handler, 193 | SensorEntityDescription( 194 | key=ATTR_METABOLIC, 195 | translation_key="metabolic_age", 196 | suggested_display_precision=0, 197 | state_class=SensorStateClass.MEASUREMENT, 198 | ), 199 | Metric.METABOLIC_AGE, 200 | ), 201 | BodyScaleSensor( 202 | handler, 203 | SensorEntityDescription( 204 | key=ATTR_BODY_SCORE, 205 | translation_key="body_score", 206 | suggested_display_precision=0, 207 | state_class=SensorStateClass.MEASUREMENT, 208 | ), 209 | Metric.BODY_SCORE, 210 | ), 211 | ] 212 | ) 213 | 214 | async_add_entities(new_sensors) 215 | 216 | 217 | class BodyScaleSensor(BodyScaleBaseEntity, SensorEntity): 218 | """Body scale sensor.""" 219 | 220 | def __init__( 221 | self, 222 | handler: BodyScaleMetricsHandler, 223 | entity_description: SensorEntityDescription, 224 | metric: Metric, 225 | get_attributes: None | ( 226 | Callable[[StateType | datetime, Mapping[str, Any]], Mapping[str, Any]] 227 | ) = None, 228 | ): 229 | super().__init__(handler, entity_description) 230 | self._metric = metric 231 | self._get_attributes = get_attributes 232 | 233 | _attr_native_value: StateType | datetime | None = None 234 | 235 | async def async_added_to_hass(self) -> None: 236 | """Set up the event listeners now that hass is ready.""" 237 | await super().async_added_to_hass() 238 | 239 | # Update the signature of on_value to accept a Union type 240 | def on_value(value: StateType | datetime) -> None: 241 | # Convert string to datetime object for timestamp sensors 242 | if self.device_class == SensorDeviceClass.TIMESTAMP and isinstance( 243 | value, str 244 | ): 245 | try: 246 | self._attr_native_value = datetime.fromisoformat(value) 247 | except ValueError as e: 248 | _LOGGER.error( 249 | "Error converting date string to datetime object: %s", e 250 | ) 251 | self._attr_native_value = None 252 | else: 253 | self._attr_native_value = value 254 | 255 | if self._get_attributes: 256 | # Update the attribute getter call to match its new signature 257 | attributes = self._get_attributes( 258 | self._attr_native_value, dict(self._handler.config) 259 | ) 260 | self._attr_extra_state_attributes = dict(attributes) 261 | 262 | self.async_write_ha_state() 263 | 264 | self.async_on_remove(self._handler.subscribe(self._metric, on_value)) 265 | -------------------------------------------------------------------------------- /example_config/weight_impedance_update.yaml: -------------------------------------------------------------------------------- 1 | blueprint: 2 | name: "Creation and Management of User Sensors for BodyMiScale" 3 | description: "Updates user entities based on weight measurements from the scale, filtering by user-defined weight ranges." 4 | domain: automation 5 | input: 6 | weight_sensor: 7 | name: "Weight Sensor" 8 | description: "Select the weight sensor (ESPHome or BLE Monitor)" 9 | selector: 10 | entity: 11 | filter: 12 | domain: sensor 13 | impedance_sensor: 14 | name: "Impedance Sensor" 15 | description: "Select the impedance sensor (ESPHome or BLE Monitor)" 16 | selector: 17 | entity: 18 | filter: 19 | domain: sensor 20 | default: "" 21 | last_time_sensor: 22 | name: "Last Time Sensor" 23 | description: "Select the last time sensor (ESPHome or BLE Monitor)" 24 | selector: 25 | entity: 26 | filter: 27 | domain: sensor 28 | default: "" 29 | 30 | num_users: 31 | name: "Number of Users" 32 | description: "Select the number of users to track" 33 | selector: 34 | select: 35 | options: 36 | - "1" 37 | - "2" 38 | - "3" 39 | - "4" 40 | - "5" 41 | 42 | ## User 1 (Always required) 43 | user1_name: 44 | name: "User 1 Name" 45 | selector: 46 | text: {} 47 | 48 | user1_min_weight: 49 | name: "User 1 Min Weight" 50 | selector: 51 | number: 52 | min: 1 53 | max: 200 54 | unit_of_measurement: "kg" 55 | default: 1 56 | 57 | user1_max_weight: 58 | name: "User 1 Max Weight" 59 | selector: 60 | number: 61 | min: 1 62 | max: 200 63 | unit_of_measurement: "kg" 64 | default: 100 65 | 66 | user1_weight: 67 | name: "User 1 Weight Entity" 68 | selector: 69 | entity: 70 | filter: 71 | domain: input_number 72 | 73 | user1_impedance: 74 | name: "User 1 Impedance Entity" 75 | selector: 76 | entity: 77 | filter: 78 | domain: input_number 79 | default: "" 80 | 81 | user1_last_time: 82 | name: "User 1 Last Measurement Time" 83 | selector: 84 | entity: 85 | filter: 86 | domain: input_datetime 87 | default: "" 88 | 89 | ## User 2 (Conditionnel) 90 | user2_name: 91 | name: "User 2 Name" 92 | selector: 93 | text: {} 94 | default: "" 95 | user2_min_weight: 96 | name: "User 2 Min Weight" 97 | selector: 98 | number: 99 | min: 1 100 | max: 200 101 | unit_of_measurement: "kg" 102 | default: 1 103 | user2_max_weight: 104 | name: "User 2 Max Weight" 105 | selector: 106 | number: 107 | min: 1 108 | max: 200 109 | unit_of_measurement: "kg" 110 | default: 100 111 | user2_weight: 112 | name: "User 2 Weight Entity" 113 | selector: 114 | entity: 115 | filter: 116 | domain: input_number 117 | default: "" 118 | user2_impedance: 119 | name: "User 2 Impedance Entity" 120 | selector: 121 | entity: 122 | filter: 123 | domain: input_number 124 | default: "" 125 | user2_last_time: 126 | name: "User 2 Last Measurement Time" 127 | selector: 128 | entity: 129 | filter: 130 | domain: input_datetime 131 | default: "" 132 | 133 | ## user 3 (Conditionnel) 134 | user3_name: 135 | name: "user 3 Name" 136 | selector: 137 | text: {} 138 | default: "" 139 | user3_min_weight: 140 | name: "user 3 Min Weight" 141 | selector: 142 | number: 143 | min: 1 144 | max: 200 145 | unit_of_measurement: "kg" 146 | default: 1 147 | user3_max_weight: 148 | name: "user 3 Max Weight" 149 | selector: 150 | number: 151 | min: 1 152 | max: 200 153 | unit_of_measurement: "kg" 154 | default: 100 155 | user3_weight: 156 | name: "user 3 Weight Entity" 157 | selector: 158 | entity: 159 | filter: 160 | domain: input_number 161 | default: "" 162 | user3_impedance: 163 | name: "user 3 Impedance Entity" 164 | selector: 165 | entity: 166 | filter: 167 | domain: input_number 168 | default: "" 169 | user3_last_time: 170 | name: "user 3 Last Measurement Time" 171 | selector: 172 | entity: 173 | filter: 174 | domain: input_datetime 175 | default: "" 176 | 177 | ## user 4 (Conditionnel) 178 | user4_name: 179 | name: "user 4 Name" 180 | selector: 181 | text: {} 182 | default: "" 183 | user4_min_weight: 184 | name: "user 4 Min Weight" 185 | selector: 186 | number: 187 | min: 1 188 | max: 200 189 | unit_of_measurement: "kg" 190 | default: 1 191 | user4_max_weight: 192 | name: "user 4 Max Weight" 193 | selector: 194 | number: 195 | min: 1 196 | max: 200 197 | unit_of_measurement: "kg" 198 | default: 100 199 | user4_weight: 200 | name: "user 4 Weight Entity" 201 | selector: 202 | entity: 203 | filter: 204 | domain: input_number 205 | default: "" 206 | user4_impedance: 207 | name: "user 4 Impedance Entity" 208 | selector: 209 | entity: 210 | filter: 211 | domain: input_number 212 | default: "" 213 | user4_last_time: 214 | name: "user 4 Last Measurement Time" 215 | selector: 216 | entity: 217 | filter: 218 | domain: input_datetime 219 | default: "" 220 | 221 | ## user 5 (Conditionnel) 222 | user5_name: 223 | name: "user 5 Name" 224 | selector: 225 | text: {} 226 | default: "" 227 | user5_min_weight: 228 | name: "user 5 Min Weight" 229 | selector: 230 | number: 231 | min: 1 232 | max: 200 233 | unit_of_measurement: "kg" 234 | default: 1 235 | user5_max_weight: 236 | name: "user 5 Max Weight" 237 | selector: 238 | number: 239 | min: 1 240 | max: 200 241 | unit_of_measurement: "kg" 242 | default: 100 243 | user5_weight: 244 | name: "user 5 Weight Entity" 245 | selector: 246 | entity: 247 | filter: 248 | domain: input_number 249 | default: "" 250 | user5_impedance: 251 | name: "user 5 Impedance Entity" 252 | selector: 253 | entity: 254 | filter: 255 | domain: input_number 256 | default: "" 257 | user5_last_time: 258 | name: "user 5 Last Measurement Time" 259 | selector: 260 | entity: 261 | filter: 262 | domain: input_datetime 263 | default: "" 264 | 265 | variables: 266 | weight_sensor: !input weight_sensor 267 | impedance_sensor: !input impedance_sensor 268 | last_time_sensor: !input last_time_sensor 269 | num_users: !input num_users 270 | users: 271 | - enabled: true 272 | name: !input user1_name 273 | min_weight: !input user1_min_weight 274 | max_weight: !input user1_max_weight 275 | weight: !input user1_weight 276 | impedance: !input user1_impedance 277 | last_time: !input user1_last_time 278 | - enabled: "{{ num_users | int >= 2 }}" 279 | name: !input user2_name 280 | min_weight: !input user2_min_weight 281 | max_weight: !input user2_max_weight 282 | weight: !input user2_weight 283 | impedance: !input user2_impedance 284 | last_time: !input user2_last_time 285 | - enabled: "{{ num_users | int >= 3 }}" 286 | name: !input user3_name 287 | min_weight: !input user3_min_weight 288 | max_weight: !input user3_max_weight 289 | weight: !input user3_weight 290 | impedance: !input user3_impedance 291 | last_time: !input user3_last_time 292 | - enabled: "{{ num_users | int >= 4 }}" 293 | name: !input user4_name 294 | min_weight: !input user4_min_weight 295 | max_weight: !input user4_max_weight 296 | weight: !input user4_weight 297 | impedance: !input user4_impedance 298 | last_time: !input user4_last_time 299 | - enabled: "{{ num_users | int >= 5 }}" 300 | name: !input user5_name 301 | min_weight: !input user5_min_weight 302 | max_weight: !input user5_max_weight 303 | weight: !input user5_weight 304 | impedance: !input user5_impedance 305 | last_time: !input user5_last_time 306 | 307 | trigger: 308 | - trigger: state 309 | entity_id: !input weight_sensor 310 | 311 | action: 312 | - delay: "00:00:15" 313 | 314 | - variables: 315 | weight: "{{ states(weight_sensor) | float(0) }}" 316 | impedance: "{{ states(impedance_sensor) | float(0) }}" 317 | last_time: >- 318 | {% if last_time_sensor != '' and states(last_time_sensor) not in ['unavailable', 'unknown', 'none'] and states(last_time_sensor) is defined %} 319 | {{ states(last_time_sensor) | as_datetime | as_local }} 320 | {% else %} 321 | none 322 | {% endif %} 323 | 324 | - condition: template 325 | value_template: "{{ weight != 0 and weight != 'unknown' and weight != 'unavailable' }}" 326 | 327 | - repeat: 328 | count: "{{ num_users | int }}" 329 | sequence: 330 | - variables: 331 | user: "{{ users[repeat.index - 1] }}" 332 | user_name: "{{ user.name }}" 333 | user_min_weight: "{{ user.min_weight }}" 334 | user_max_weight: "{{ user.max_weight }}" 335 | user_weight: "{{ user.weight }}" 336 | user_impedance: "{{ user.impedance }}" 337 | user_last_time: "{{ user.last_time }}" 338 | 339 | - condition: template 340 | value_template: "{{ user.enabled and user_min_weight <= weight <= user_max_weight }}" 341 | 342 | - choose: 343 | - conditions: 344 | - "{{ user_weight != '' }}" 345 | sequence: 346 | - action: input_number.set_value 347 | target: 348 | entity_id: "{{ user_weight }}" 349 | data: 350 | value: "{{ weight }}" 351 | 352 | - choose: 353 | - conditions: 354 | - "{{ user_impedance != '' and impedance | float(0) != 0 }}" 355 | sequence: 356 | - action: input_number.set_value 357 | target: 358 | entity_id: "{{ user_impedance }}" 359 | data: 360 | value: "{{ impedance }}" 361 | 362 | - choose: 363 | - conditions: 364 | - "{{ user_last_time != '' and last_time != none and last_time is defined}}" 365 | sequence: 366 | - action: input_datetime.set_datetime 367 | target: 368 | entity_id: "{{ user_last_time }}" 369 | data: 370 | datetime: "{{ last_time }}" 371 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bodymiscale 2 | 3 | [![GH-release](https://img.shields.io/github/v/release/dckiller51/bodymiscale.svg?style=flat-square)](https://github.com/dckiller51/bodymiscale/releases) 4 | [![GH-downloads](https://img.shields.io/github/downloads/dckiller51/bodymiscale/total?style=flat-square)](https://github.com/dckiller51/bodymiscale/releases) 5 | [![GH-last-commit](https://img.shields.io/github/last-commit/dckiller51/bodymiscale.svg?style=flat-square)](https://github.com/dckiller51/bodymiscale/commits/main) 6 | [![GH-code-size](https://img.shields.io/github/languages/code-size/dckiller51/bodymiscale.svg?color=red&style=flat-square)](https://github.com/dckiller51/bodymiscale) 7 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=flat-square)](https://github.com/hacs) 8 | 9 | ## Track your body composition closely with Bodymiscale 10 | 11 | With this Home Assistant integration, track your body composition closely using data from your weight sensor. You will get detailed information for accurate tracking. 12 | 13 | ## How it works 14 | 15 | Bodymiscale retrieves data from your existing weight sensor (and optionally, your impedance sensor) in Home Assistant. It then calculates various body composition metrics using standard formulas. These calculations are performed locally within Home Assistant. 16 | 17 | Here's a breakdown of the process: 18 | 19 | 1. **Data Input:** Bodymiscale relies on data provided by your configured weight sensor. This can be: 20 | 21 | - A `sensor` entity that's already integrated with Home Assistant. 22 | - An `input_number` entity that's already integrated with Home Assistant. 23 | 24 | 2. **Optional Impedance Data:** If you have configured an impedance sensor, Bodymiscale will also use this data to calculate more advanced metrics. 25 | 26 | 3. **Calculations:** Bodymiscale uses standard, scientifically recognized formulas to derive various metrics like BMI, basal metabolism, body fat percentage, and others. 27 | 28 | 4. **Output:** The calculated metrics are then made available as new `sensor` entities within Home Assistant. You can then use these sensors in your Lovelace dashboards, automations, or any other Home Assistant feature. 29 | 30 | **Key Considerations:** 31 | 32 | - **Accuracy:** The accuracy of the calculated metrics depends heavily on the accuracy of your weight and (if used) impedance measurements. Ensure your sensors are calibrated and providing reliable data. 33 | - **No External Services:** Bodymiscale performs all calculations locally within your Home Assistant instance. No data is sent to external services or the internet. 34 | 35 | **Example:** 36 | 37 | Let's say you've configured a weight sensor called `sensor.my_weight`. When you add the Bodymiscale integration, it will: 38 | 39 | 1. Read the current value of `sensor.my_weight`. 40 | 2. Use this value (along with other information like age and gender you provided during configuration) to calculate your BMI, BMR, etc. 41 | 3. Create new sensors like `sensor.myname_bmi`, `sensor.myname_bmr`, etc., containing these calculated values. 42 | 43 | ## Prerequisites 44 | 45 | Before installing Bodymiscale, ensure you have the following: 46 | 47 | 1. **A user-dedicated weight sensor in Home Assistant:** There is no relationship between Bodymiscale and a specific connected scale. Bodymiscale works with any weight sensor integrated into Home Assistant. This can be: 48 | 49 | - A user-dedicated `sensor` entity. **Warning:** Using a sensor directly from a scale can lead to complications. 50 | - An `input_number` entity offers a robust solution for recording your weight measurements in Home Assistant, with the crucial advantage of data persistence even after a system restart. 51 | 52 | **Important:** It is mandatory that each Bodymiscale user has their own dedicated weight sensor. This must be persistent, meaning that when you restart Home Assistant, the data is still available. Indeed, Bodymiscale retrieves the sensor value at the time of its initialization, which will distort the calculation data with an unavailable or zero sensor. 53 | 54 | 2. **Home Assistant installed.** 55 | 56 | **(Optional) User-dedicated impedance sensor:** 57 | 58 | If you plan to use an impedance sensor for more advanced metrics (lean body mass, body fat mass, etc.), make sure you also have a dedicated impedance sensor configured in Home Assistant. The same recommendation applies: each user should have their own dedicated impedance sensor for best results. 59 | 60 | **(Optional) Last weigh-in sensor dedicated to the user:** 61 | 62 | If you plan to integrate your own last weigh-in sensor, make sure a dedicated sensor is properly configured in Home Assistant. The same recommendation applies: each user should have their own last weigh-in sensor for optimal results. 63 | 64 | ## Generated data 65 | 66 | Bodymiscale calculates the following data: 67 | 68 | | Data | Description | Impedance sensor required | 69 | | ---------------- | --------------------------------------------------------------------- | ------------------------- | 70 | | Weight | Measured weight | No | 71 | | Height | User height | No | 72 | | Age | User age | No | 73 | | BMI | Body Mass Index | No | 74 | | Basal Metabolism | Number of calories your body burns at rest | No | 75 | | Visceral Fat | Estimation of fat around organs | No | 76 | | Ideal Weight | Recommended weight based on your height and age | No | 77 | | BMI Category | BMI category (e.g., "Underweight", "Normal", "Overweight", "Obesity") | No | 78 | | Lean Body Mass | Body mass without fat | Yes | 79 | | Body Fat Mass | Body fat mass | Yes | 80 | | Water | Percentage of water in the body | Yes | 81 | | Bone Mass | Bone mass | Yes | 82 | | Muscle Mass | Muscle mass | Yes | 83 | | Ideal Fat Mass | Recommended body fat range | Yes | 84 | | Protein | Percentage of protein in the body | Yes | 85 | | Body Type | Body type classification | Yes | 86 | 87 | ## Installation 88 | 89 | ### Via HACS 90 | 91 | 1. Open HACS in Home Assistant. 92 | 2. Go to the "Integrations" tab. 93 | 3. Search for "Bodymiscale". 94 | 4. Click "Install". 95 | 96 | ### Manual 97 | 98 | 1. Download the latest version archive from the [releases page](https://github.com/dckiller51/bodymiscale/releases). 99 | 2. Unzip the archive. 100 | 3. Copy the _entire_ `custom_components/bodymiscale` folder into your `config/custom_components` folder in Home Assistant. The final path should be `/config/custom_components/bodymiscale`. 101 | 4. Restart Home Assistant. 102 | 103 | ## Configuration 104 | 105 | 1. Open Home Assistant and go to "Settings" -> "Devices & Services" -> "Add Integration". 106 | 2. Search for "Bodymiscale". 107 | 3. **Personalize your integration:** 108 | - **First Name (or other identifier):** Enter your first name or another identifier. **Important:** This identifier will determine the name of your Bodymiscale component in Home Assistant, as well as the names of all sensors created by it. Choose a clear and relevant name. 109 | - **Date of Birth:** Enter your date of birth in YYYY-MM-DD format. 110 | - **Gender:** Select your gender (Male/Female). 111 | 4. **Select your weight sensor:** Choose the existing weight sensor in Home Assistant (e.g., a `sensor`, or an `input_number`). 112 | - **Important Recommendation:** It is **strongly recommended** that each Bodymiscale user has their own dedicated weight sensor. Using a shared weight sensor (e.g., one directly linked to a scale) can cause issues when Home Assistant restarts. This is because Bodymiscale retrieves the sensor's value upon initialization, which can skew calculations if multiple users weigh themselves successively on the same scale before the restart. 113 | 5. **Impedance sensor (optional):** If you have an impedance sensor, select it here. This sensor is required to calculate some advanced metrics (lean body mass, body fat mass, etc.). 114 | - **Recommendation:** As with the weight sensor, it is best for each user to have their own dedicated impedance sensor to avoid issues during restarts. 115 | 6. **Last measurement time sensor (optional):** 116 | If you have a last weigh-in sensor, select it here (e.g., a `sensor`, or an `input_datetime`). This sensor is used to record the date and time of the most recent measurement. 117 | Recommendation: Just like the weight and impedance sensors, it is strongly recommended that each user has their own dedicated last weigh-in sensor to prevent conflicts or errors during Home Assistant restarts. 118 | 7. Click "Save". 119 | 120 | **Explanation of choices:** 121 | 122 | - **First Name/Identifier:** This field is important because it allows you to personalize the integration and avoid conflicts if multiple people use Bodymiscale in your home. The name you choose will be used to name the entities created by the integration (e.g., `sensor.firstname_weight`, `sensor.firstname_bmi`, etc.). 123 | - **Date of Birth and Gender:** This information is needed to calculate some metrics, such as basal metabolism. 124 | 125 | **Tips:** 126 | 127 | - If you do not have an impedance sensor, some metrics will not be unavailable. You can still use Bodymiscale to get basic information (weight, BMI, etc.). 128 | 129 | ## FAQ 130 | 131 | - **Why are some values missing?** You must have an impedance sensor configured for Bodymiscale to calculate lean body mass, body fat mass, etc. 132 | - **How accurate is the data?** Bodymiscale uses standard formulas, but the accuracy of measurements depends on your scale and its configuration. 133 | 134 | ## Helps create weight, impedance and/or last weighing data persistently 135 | 136 | For a detailed configuration to integrate data persistence and multi-user management, please refer to the [example_config](example_config/) folder. 137 | 138 | This folder contains example configurations for generating weight, impedance, and last weighing sensors, using both ESPHome and Home Assistant. 139 | 140 | ### Configuration Examples in the example_config Folder 141 | 142 | The [example_config](example_config/) folder contains the following example configuration files: 143 | 144 | - **`esphome_configuration.yaml`**: Complete ESPHome configuration to generate sensors directly from the Xiaomi Mi Scale. 145 | - **`weight_impedance_update.yaml`**: Home Assistant configuration to generate sensors via the ESPHome integration or BLE Monitor. 146 | - **`interactive_notification_user_selection_weight_data_update.yaml`**: Example automation created from the blueprint for user selection and weight data update via interactive notification. 147 | 148 | Please consult the configuration files within the [example_config](example_config/) folder for detailed information on generating weight, impedance, and last weighing sensors. 149 | 150 | ## Useful links 151 | 152 | - [Lovelace Card for Bodymiscale](https://github.com/dckiller51/lovelace-body-miscale-card) 153 | - [ESPHome for Xiaomi Mi Scale](https://esphome.io/components/sensor/xiaomi_miscale.html) 154 | - [BLE Monitor for Xiaomi Mi Scale](https://github.com/custom-components/ble_monitor) 155 | -------------------------------------------------------------------------------- /example_config/esphome_configuration.yaml: -------------------------------------------------------------------------------- 1 | esphome: 2 | name: xiaomi-miscale 3 | friendly_name: xiaomi_miscale 4 | 5 | esp32: 6 | board: esp32dev 7 | framework: 8 | type: arduino 9 | 10 | wifi: 11 | ssid: !secret ssid 12 | password: !secret wpa2 13 | fast_connect: true 14 | 15 | captive_portal: 16 | 17 | logger: 18 | 19 | api: 20 | 21 | ota: 22 | - platform: esphome 23 | password: !secret ota_password 24 | 25 | esp32_ble_tracker: 26 | 27 | time: 28 | - platform: homeassistant 29 | id: esptime 30 | 31 | # Configuration 32 | substitutions: 33 | mac_address: "5C:CA:D3:70:D4:A2" 34 | user1_name: "Aurelien" 35 | weight_user1_min: "70" 36 | weight_user1_max: "74.99" 37 | user2_name: "Noham" 38 | weight_user2_min: "20" 39 | weight_user2_max: "30" 40 | # user3_name: "Name3" 41 | # weight_user3_min: "60" 42 | # weight_user3_max: "69.99" 43 | # user4_name: "Name4" 44 | # weight_user4_min: "75" 45 | # weight_user4_max: "79.99" 46 | # user5_name: "Name5" 47 | # weight_user5_min: "50" 48 | # weight_user5_max: "59.99" 49 | 50 | number: 51 | # User1 52 | - platform: template 53 | name: "Weight Num ${user1_name}" 54 | id: weight_num_user1 55 | restore_value: true 56 | optimistic: true 57 | min_value: 0 58 | max_value: 200 59 | step: 0.1 60 | unit_of_measurement: "kg" 61 | internal: true 62 | 63 | - platform: template 64 | name: "Impedance Num ${user1_name}" 65 | id: impedance_num_user1 66 | restore_value: true 67 | optimistic: true 68 | min_value: 0 69 | max_value: 1000 70 | step: 0.1 71 | unit_of_measurement: "ohm" 72 | internal: true 73 | 74 | - platform: template 75 | name: "Last Time Measurement Num ${user1_name}" 76 | id: last_time_measurement_num_user1 77 | min_value: 0 78 | max_value: 4294967295 79 | step: 0.1 80 | optimistic: true 81 | restore_value: true 82 | internal: true 83 | 84 | # User2 85 | - platform: template 86 | name: "Weight Num ${user2_name}" 87 | id: weight_num_user2 88 | restore_value: true 89 | optimistic: true 90 | min_value: 0 91 | max_value: 200 92 | step: 0.1 93 | unit_of_measurement: "kg" 94 | internal: true 95 | 96 | - platform: template 97 | name: "Impedance Num ${user2_name}" 98 | id: impedance_num_user2 99 | restore_value: true 100 | optimistic: true 101 | min_value: 0 102 | max_value: 1000 103 | step: 0.1 104 | unit_of_measurement: "ohm" 105 | internal: true 106 | 107 | - platform: template 108 | name: "Last Time Measurement Num ${user2_name}" 109 | id: last_time_measurement_num_user2 110 | min_value: 0 111 | max_value: 4294967295 112 | step: 0.1 113 | optimistic: true 114 | restore_value: true 115 | internal: true 116 | 117 | # User3 118 | # - platform: template 119 | # name: "Weight Num ${user3_name}" 120 | # id: weight_num_user3 121 | # restore_value: true 122 | # optimistic: true 123 | # min_value: 0 124 | # max_value: 200 125 | # step: 0.1 126 | # unit_of_measurement: 'kg' 127 | # internal: true 128 | 129 | # - platform: template 130 | # name: "Impedance Num ${user3_name}" 131 | # id: impedance_num_user3 132 | # restore_value: true 133 | # optimistic: true 134 | # min_value: 0 135 | # max_value: 1000 136 | # step: 0.1 137 | # unit_of_measurement: 'ohm' 138 | # internal: true 139 | 140 | # - platform: template 141 | # name: "Last Time Measurement Num ${user3_name}" 142 | # id: last_time_measurement_num_user3 143 | # min_value: 0 144 | # max_value: 4294967295 145 | # step: 0.1 146 | # optimistic: true 147 | # restore_value: true 148 | # internal: true 149 | 150 | # User4 151 | # - platform: template 152 | # name: "Weight Num ${user4_name}" 153 | # id: weight_num_user4 154 | # restore_value: true 155 | # optimistic: true 156 | # min_value: 0 157 | # max_value: 200 158 | # step: 0.1 159 | # unit_of_measurement: 'kg' 160 | # internal: true 161 | 162 | # - platform: template 163 | # name: "Impedance Num ${user4_name}" 164 | # id: impedance_num_user4 165 | # restore_value: true 166 | # optimistic: true 167 | # min_value: 0 168 | # max_value: 1000 169 | # step: 0.1 170 | # unit_of_measurement: 'ohm' 171 | # internal: true 172 | 173 | # - platform: template 174 | # name: "Last Time Measurement Num ${user4_name}" 175 | # id: last_time_measurement_num_user4 176 | # min_value: 0 177 | # max_value: 4294967295 178 | # step: 0.1 179 | # optimistic: true 180 | # restore_value: true 181 | # internal: true 182 | 183 | # User5 184 | # - platform: template 185 | # name: "Weight Num ${user5_name}" 186 | # id: weight_num_user5 187 | # restore_value: true 188 | # optimistic: true 189 | # min_value: 0 190 | # max_value: 200 191 | # step: 0.1 192 | # unit_of_measurement: 'kg' 193 | # internal: true 194 | 195 | # - platform: template 196 | # name: "Impedance Num ${user5_name}" 197 | # id: impedance_num_user5 198 | # restore_value: true 199 | # optimistic: true 200 | # min_value: 0 201 | # max_value: 1000 202 | # step: 0.1 203 | # unit_of_measurement: 'ohm' 204 | # internal: true 205 | 206 | # - platform: template 207 | # name: "Last Time Measurement Num ${user5_name}" 208 | # id: last_time_measurement_num_user5 209 | # min_value: 0 210 | # max_value: 4294967295 211 | # step: 0.1 212 | # optimistic: true 213 | # restore_value: true 214 | # internal: true 215 | 216 | sensor: 217 | # Weight sensor User 1 218 | - platform: template 219 | name: "Weight ${user1_name}" 220 | id: weight_user1 221 | unit_of_measurement: "kg" 222 | icon: mdi:scale-bathroom 223 | accuracy_decimals: 2 224 | state_class: measurement 225 | lambda: "return id(weight_num_user1).state;" 226 | 227 | # Impedance sensor User 1 228 | - platform: template 229 | name: "Impedance ${user1_name}" 230 | id: impedance_user1 231 | unit_of_measurement: "ohm" 232 | icon: mdi:omega 233 | state_class: measurement 234 | accuracy_decimals: 0 235 | lambda: "return id(impedance_num_user1).state;" 236 | 237 | # Last Measurement Time sensor User 1 238 | - platform: template 239 | name: "Last Time Measurement ${user1_name}" 240 | id: last_time_measurement_user1 241 | lambda: "return id(last_time_measurement_num_user1).state;" 242 | device_class: timestamp 243 | 244 | # Weight sensor User 2 245 | - platform: template 246 | name: "Weight ${user2_name}" 247 | id: weight_user2 248 | unit_of_measurement: "kg" 249 | icon: mdi:scale-bathroom 250 | state_class: measurement 251 | accuracy_decimals: 2 252 | lambda: "return id(weight_num_user2).state;" 253 | 254 | # Impedance sensor User 2 255 | - platform: template 256 | name: "Impedance ${user2_name}" 257 | id: impedance_user2 258 | unit_of_measurement: "ohm" 259 | icon: mdi:omega 260 | state_class: measurement 261 | accuracy_decimals: 0 262 | lambda: "return id(impedance_num_user2).state;" 263 | 264 | # Last Measurement Time sensor User 2 265 | - platform: template 266 | name: "Last Time Measurement ${user2_name}" 267 | id: last_time_measurement_user2 268 | lambda: "return id(last_time_measurement_num_user2).state;" 269 | device_class: timestamp 270 | 271 | # Weight sensor User 3 272 | # - platform: template 273 | # name: "Weight ${user3_name}" 274 | # id: weight_user3 275 | # unit_of_measurement: 'kg' 276 | # icon: mdi:scale-bathroom 277 | # state_class: measurement 278 | # accuracy_decimals: 2 279 | # lambda: 'return id(weight_num_user3).state;' 280 | 281 | # Impedance sensor User 3 282 | # - platform: template 283 | # name: "Impedance ${user3_name}" 284 | # id: impedance_user3 285 | # unit_of_measurement: 'ohm' 286 | # icon: mdi:omega 287 | # state_class: measurement 288 | # accuracy_decimals: 0 289 | # lambda: 'return id(impedance_num_user3).state;' 290 | 291 | # Last Measurement Time sensor User 3 292 | # - platform: template 293 | # name: "Last Time Measurement ${user3_name}" 294 | # id: last_time_measurement_user3 295 | # lambda: 'return id(last_time_measurement_num_user3).state;' 296 | # device_class: timestamp 297 | 298 | # Weight sensor user 4 299 | # - platform: template 300 | # name: "Weight ${user4_name}" 301 | # id: weight_user4 302 | # unit_of_measurement: 'kg' 303 | # icon: mdi:scale-bathroom 304 | # state_class: measurement 305 | # accuracy_decimals: 2 306 | # lambda: 'return id(weight_num_user4).state;' 307 | 308 | # Impedance sensor user 4 309 | # - platform: template 310 | # name: "Impedance ${user4_name}" 311 | # id: impedance_user4 312 | # unit_of_measurement: 'ohm' 313 | # icon: mdi:omega 314 | # state_class: measurement 315 | # accuracy_decimals: 0 316 | # lambda: 'return id(impedance_num_user4).state;' 317 | 318 | # Last Measurement Time sensor user 4 319 | # - platform: template 320 | # name: "Last Time Measurement ${user4_name}" 321 | # id: last_time_measurement_user4 322 | # lambda: 'return id(last_time_measurement_num_user4).state;' 323 | # device_class: timestamp 324 | 325 | # Weight sensor user 5 326 | # - platform: template 327 | # name: "Weight ${user5_name}" 328 | # id: weight_user5 329 | # unit_of_measurement: 'kg' 330 | # icon: mdi:scale-bathroom 331 | # state_class: measurement 332 | # accuracy_decimals: 2 333 | # lambda: 'return id(weight_num_user5).state;' 334 | 335 | # Impedance sensor user 5 336 | # - platform: template 337 | # name: "Impedance ${user5_name}" 338 | # id: impedance_user5 339 | # unit_of_measurement: 'ohm' 340 | # icon: mdi:omega 341 | # state_class: measurement 342 | # accuracy_decimals: 0 343 | # lambda: 'return id(impedance_num_user5).state;' 344 | 345 | # Last Measurement Time sensor user 5 346 | # - platform: template 347 | # name: "Last Time Measurement ${user5_name}" 348 | # id: last_time_measurement_user5 349 | # lambda: 'return id(last_time_measurement_num_user5).state;' 350 | # device_class: timestamp 351 | 352 | # Weight sensor Xiaomi Mi Scale 353 | - platform: xiaomi_miscale 354 | mac_address: ${mac_address} 355 | weight: 356 | name: "Xiaomi Mi Scale Weight" 357 | id: xiaomi_weight 358 | on_value: 359 | then: 360 | - if: 361 | condition: 362 | lambda: 'return (id(xiaomi_weight).state >= atof("${weight_user1_min}")) && (id(xiaomi_weight).state <= atof("${weight_user1_max}"));' 363 | then: 364 | - number.set: 365 | id: weight_num_user1 366 | value: !lambda "return id(xiaomi_weight).state;" 367 | - number.set: 368 | id: last_time_measurement_num_user1 369 | value: !lambda "return id(esptime).now().timestamp;" 370 | - if: 371 | condition: 372 | lambda: 'return (id(xiaomi_weight).state >= atof("${weight_user2_min}")) && (id(xiaomi_weight).state <= atof("${weight_user2_max}"));' 373 | then: 374 | - number.set: 375 | id: weight_num_user2 376 | value: !lambda "return id(xiaomi_weight).state;" 377 | - number.set: 378 | id: last_time_measurement_num_user2 379 | value: !lambda "return id(esptime).now().timestamp;" 380 | # - if: 381 | # condition: 382 | # lambda: 'return (id(xiaomi_weight).state >= atof("${weight_user3_min}")) && (id(xiaomi_weight).state <= atof("${weight_user3_max}"));' 383 | # then: 384 | # - number.set: 385 | # id: weight_num_user3 386 | # value: !lambda 'return id(xiaomi_weight).state;' 387 | # - number.set: 388 | # id: last_time_measurement_num_user3 389 | # value: !lambda 'return id(esptime).now().timestamp;' 390 | # - if: 391 | # condition: 392 | # lambda: 'return (id(xiaomi_weight).state >= atof("${weight_user4_min}")) && (id(xiaomi_weight).state <= atof("${weight_user4_max}"));' 393 | # then: 394 | # - number.set: 395 | # id: weight_num_user4 396 | # value: !lambda 'return id(xiaomi_weight).state;' 397 | # - number.set: 398 | # id: last_time_measurement_num_user4 399 | # value: !lambda 'return id(esptime).now().timestamp;' 400 | # - if: 401 | # condition: 402 | # lambda: 'return (id(xiaomi_weight).state >= atof("${weight_user5_min}")) && (id(xiaomi_weight).state <= atof("${weight_user5_max}"));' 403 | # then: 404 | # - number.set: 405 | # id: weight_num_user5 406 | # value: !lambda 'return id(xiaomi_weight).state;' 407 | # - number.set: 408 | # id: last_time_measurement_num_user5 409 | # value: !lambda 'return id(esptime).now().timestamp;' 410 | # Impedance sensor Xiaomi Mi Scale 411 | impedance: 412 | name: "Xiaomi Mi Scale Impedance" 413 | id: xiaomi_impedance 414 | on_value: 415 | then: 416 | - if: 417 | condition: 418 | lambda: 'return (id(xiaomi_weight).state >= atof("${weight_user1_min}")) && (id(xiaomi_weight).state <= atof("${weight_user1_max}"));' 419 | then: 420 | - number.set: 421 | id: impedance_num_user1 422 | value: !lambda "return id(xiaomi_impedance).state;" 423 | - if: 424 | condition: 425 | lambda: 'return (id(xiaomi_weight).state >= atof("${weight_user2_min}")) && (id(xiaomi_weight).state <= atof("${weight_user2_max}"));' 426 | then: 427 | - number.set: 428 | id: impedance_num_user2 429 | value: !lambda "return id(xiaomi_impedance).state;" 430 | # - if: 431 | # condition: 432 | # lambda: 'return (id(xiaomi_weight).state >= atof("${weight_user3_min}")) && (id(xiaomi_weight).state <= atof("${weight_user3_max}"));' 433 | # then: 434 | # - number.set: 435 | # id: impedance_num_user3 436 | # value: !lambda 'return id(xiaomi_impedance).state;' 437 | # - if: 438 | # condition: 439 | # lambda: 'return (id(xiaomi_weight).state >= atof("${weight_user4_min}")) && (id(xiaomi_weight).state <= atof("${weight_user4_max}"));' 440 | # then: 441 | # - number.set: 442 | # id: impedance_num_user4 443 | # value: !lambda 'return id(xiaomi_impedance).state;' 444 | # - if: 445 | # condition: 446 | # lambda: 'return (id(xiaomi_weight).state >= atof("${weight_user5_min}")) && (id(xiaomi_weight).state <= atof("${weight_user5_max}"));' 447 | # then: 448 | # - number.set: 449 | # id: impedance_num_user5 450 | # value: !lambda 'return id(xiaomi_impedance).state;' 451 | --------------------------------------------------------------------------------