From daa545cbde19e7cde69188c844b7b8349fe3f7a6 Mon Sep 17 00:00:00 2001 From: Ron Date: Wed, 23 Oct 2024 00:12:49 +0100 Subject: [PATCH] Body metrics support --- README.md | 28 +- .../etekcity_fitness_scale_ble/__init__.py | 6 +- .../etekcity_fitness_scale_ble/config_flow.py | 271 +++++++++++- .../etekcity_fitness_scale_ble/const.py | 8 + .../etekcity_fitness_scale_ble/coordinator.py | 98 ++++- .../etekcity_fitness_scale_ble/manifest.json | 4 +- .../etekcity_fitness_scale_ble/sensor.py | 386 +++++++++++------- .../etekcity_fitness_scale_ble/strings.json | 38 +- .../translations/en.json | 38 +- requirements.txt | 2 +- 10 files changed, 681 insertions(+), 198 deletions(-) diff --git a/README.md b/README.md index 035ac80..602b631 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,25 @@ # Etekcity Fitness Scale BLE Integration for Home Assistant -This custom integration allows you to connect your Etekcity Bluetooth Low Energy (BLE) fitness scale to Home Assistant. It provides real-time weight measurements directly in your Home Assistant instance, without requiring an internet connection or the VeSync app. +This custom integration allows you to connect your Etekcity Bluetooth Low Energy (BLE) fitness scale to Home Assistant. It provides real-time weight measurements and body composition metrics directly in your Home Assistant instance, without requiring an internet connection or the VeSync app. ## Features - Automatic discovery of Etekcity BLE fitness scales -- Real-time weight measurements -- Customizable display units (kg, lb, st) +- Real-time weight and impedance measurements +- Optional body composition metrics calculation including: + - Body Mass Index (BMI) + - Body Fat Percentage + - Fat Free Weight + - Subcutaneous Fat Percentage + - Visceral Fat Value + - Body Water Percentage + - Basal Metabolic Rate + - Skeletal Muscle Percentage + - Muscle Mass + - Bone Mass + - Protein Percentage + - Metabolic Age +- Customizable display units (kg, lb) - Direct Bluetooth communication (no internet or VeSync app required) **Note:** Currently, only weight measurement is supported. Future updates may include support for impedance measurements and/or impedance-based body composition estimates. @@ -35,7 +48,14 @@ This custom integration allows you to connect your Etekcity Bluetooth Low Energy 1. In Home Assistant, go to "Configuration" > "Integrations". 2. Click the "+" button to add a new integration. 3. Search for "Etekcity Fitness Scale BLE" and select it. -4. Follow the configuration steps to add your scale. +4. Follow the configuration steps: + - Choose your preferred unit system (Metric or Imperial) + - Optionally enable body composition metrics + - If body composition is enabled: + - Select your sex (Male/Female) + - Enter your birthdate + - Enter your height (in cm for Metric, or feet/inches for Imperial) + ## Supported Devices diff --git a/custom_components/etekcity_fitness_scale_ble/__init__.py b/custom_components/etekcity_fitness_scale_ble/__init__.py index 0c36864..80825ee 100755 --- a/custom_components/etekcity_fitness_scale_ble/__init__.py +++ b/custom_components/etekcity_fitness_scale_ble/__init__.py @@ -3,6 +3,8 @@ from __future__ import annotations from bleak_retry_connector import close_stale_connections_by_address + +from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -34,6 +36,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) + coordinator: ScaleDataUpdateCoordinator = hass.data[DOMAIN].pop(entry.entry_id) + await coordinator.async_stop() + bluetooth.async_rediscover_address(hass, coordinator.address) return unload_ok diff --git a/custom_components/etekcity_fitness_scale_ble/config_flow.py b/custom_components/etekcity_fitness_scale_ble/config_flow.py index 1695aae..9e9fe3c 100755 --- a/custom_components/etekcity_fitness_scale_ble/config_flow.py +++ b/custom_components/etekcity_fitness_scale_ble/config_flow.py @@ -4,26 +4,37 @@ import dataclasses import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol + from homeassistant.components.bluetooth import ( BluetoothServiceInfo, async_discovered_service_info, ) -from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_ADDRESS +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_ADDRESS, CONF_UNIT_SYSTEM, UnitOfLength, UnitOfMass from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import selector +from homeassistant.util.unit_conversion import DistanceConverter -from .const import DOMAIN +from .const import ( + CONF_BIRTHDATE, + CONF_CALC_BODY_METRICS, + CONF_FEET, + CONF_HEIGHT, + CONF_INCHES, + CONF_SEX, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) @dataclasses.dataclass(frozen=True) class Discovery: - """ - Represents a discovered Bluetooth device. + """Represents a discovered Bluetooth device. Attributes: title: The name or title of the discovered device. @@ -43,10 +54,65 @@ class ScaleConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for BT scale.""" VERSION = 1 + _entry: ConfigEntry def __init__(self) -> None: """Initialize the config flow.""" self._discovered_devices: dict[str, Discovery] = {} + self.metric_schema_dict = { + vol.Required( + CONF_SEX, + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=["Male", "Female"], + translation_key=CONF_SEX, + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required( + CONF_BIRTHDATE, + ): selector.DateSelector(), + vol.Required(CONF_HEIGHT, default=170): selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, + max=300, + unit_of_measurement=UnitOfLength.CENTIMETERS, + mode=selector.NumberSelectorMode.BOX, + ) + ), + } + + self.imperial_schema_dict = { + vol.Required( + CONF_SEX, + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=["Male", "Female"], + translation_key=CONF_SEX, + mode=selector.SelectSelectorMode.DROPDOWN, + ) + ), + vol.Required( + CONF_BIRTHDATE, + ): selector.DateSelector(), + vol.Required(CONF_FEET, default=5): selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, + max=8, + unit_of_measurement=UnitOfLength.FEET, + mode=selector.NumberSelectorMode.SLIDER, + ) + ), + vol.Required(CONF_INCHES, default=7): selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, + max=11, + unit_of_measurement=UnitOfLength.INCHES, + step=0.5, + mode=selector.NumberSelectorMode.SLIDER, + ) + ), + } async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfo @@ -65,14 +131,97 @@ async def async_step_bluetooth_confirm( ) -> FlowResult: """Confirm discovery.""" if user_input is not None: + if user_input[CONF_CALC_BODY_METRICS]: + self.context[CONF_UNIT_SYSTEM] = user_input[CONF_UNIT_SYSTEM] + return await self.async_step_body_metrics() + return self.async_create_entry( - title=self.context["title_placeholders"]["name"], data={} + title=self.context["title_placeholders"]["name"], + data={ + CONF_UNIT_SYSTEM: user_input[CONF_UNIT_SYSTEM], + CONF_CALC_BODY_METRICS: False, + }, ) - self._set_confirm_only() return self.async_show_form( step_id="bluetooth_confirm", description_placeholders=self.context["title_placeholders"], + data_schema=vol.Schema( + { + vol.Required(CONF_UNIT_SYSTEM): vol.In( + {UnitOfMass.KILOGRAMS: "Metric", UnitOfMass.POUNDS: "Imperial"} + ), + vol.Required(CONF_CALC_BODY_METRICS, default=False): cv.boolean, + } + ), + ) + + def _convert_height_measurements( + self, unit: str, user_input: dict[str, Any] + ) -> tuple[int, int, float]: + """Convert height measurements between metric and imperial units. + + Args: + unit: The unit system being used (KILOGRAMS or POUNDS) + user_input: Dictionary containing height measurements + + Returns: + tuple: Contains height in (centimeters, feet, inches) + """ + if unit == UnitOfMass.KILOGRAMS: + centimeters = user_input[CONF_HEIGHT] + half_inches = round(centimeters / 1.27) + feet = half_inches // 24 + inches = (half_inches % 24) / 2 + else: + feet = user_input[CONF_FEET] + inches = user_input[CONF_INCHES] + centimeters = round( + DistanceConverter.convert( + feet, + UnitOfLength.FEET, + UnitOfLength.CENTIMETERS, + ) + + DistanceConverter.convert( + inches, + UnitOfLength.INCHES, + UnitOfLength.CENTIMETERS, + ) + ) + + return centimeters, feet, inches + + async def async_step_body_metrics( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + unit = self.context[CONF_UNIT_SYSTEM] + if user_input is not None: + centimeters, feet, inches = self._convert_height_measurements( + unit, user_input + ) + return self.async_create_entry( + title=self.context["title_placeholders"]["name"], + data={ + CONF_UNIT_SYSTEM: unit, + CONF_CALC_BODY_METRICS: True, + CONF_SEX: user_input[CONF_SEX], + CONF_BIRTHDATE: user_input[CONF_BIRTHDATE], + CONF_HEIGHT: centimeters, + CONF_FEET: feet, + CONF_INCHES: inches, + }, + ) + + schema = ( + vol.Schema(self.metric_schema_dict) + if unit == UnitOfMass.KILOGRAMS + else vol.Schema(self.imperial_schema_dict) + ) + + return self.async_show_form( + step_id="body_metrics", + description_placeholders=self.context["title_placeholders"], + data_schema=schema, ) async def async_step_user( @@ -85,11 +234,17 @@ async def async_step_user( self._abort_if_unique_id_configured() discovery = self._discovered_devices[address] - self.context["title_placeholders"] = { - "name": discovery.title, - } + if user_input[CONF_CALC_BODY_METRICS]: + self.context[CONF_UNIT_SYSTEM] = user_input[CONF_UNIT_SYSTEM] + return await self.async_step_body_metrics() - return self.async_create_entry(title=discovery.title, data={}) + return self.async_create_entry( + title=discovery.title, + data={ + CONF_UNIT_SYSTEM: user_input[CONF_UNIT_SYSTEM], + CONF_CALC_BODY_METRICS: False, + }, + ) current_addresses = self._async_current_ids() for discovery_info in async_discovered_service_info(self.hass): @@ -97,12 +252,6 @@ async def async_step_user( if address in current_addresses or address in self._discovered_devices: continue - if discovery_info.advertisement.local_name is None: - continue - - if not (discovery_info.advertisement.local_name.startswith("Etekcity")): - continue - _LOGGER.debug("Found BT Scale") _LOGGER.debug("Scale Discovery address: %s", address) _LOGGER.debug("Scale Man Data: %s", discovery_info.manufacturer_data) @@ -112,8 +261,7 @@ async def async_step_user( _LOGGER.debug("Scale service uuids: %s", discovery_info.service_uuids) _LOGGER.debug("Scale rssi: %s", discovery_info.rssi) _LOGGER.debug( - "Scale advertisement: %s", - discovery_info.advertisement.local_name, + "Scale advertisement: %s", discovery_info.advertisement.local_name ) self._discovered_devices[address] = Discovery( title(discovery_info), discovery_info @@ -131,6 +279,89 @@ async def async_step_user( data_schema=vol.Schema( { vol.Required(CONF_ADDRESS): vol.In(titles), + vol.Required(CONF_UNIT_SYSTEM): vol.In( + {UnitOfMass.KILOGRAMS: "Metric", UnitOfMass.POUNDS: "Imperial"} + ), + vol.Required(CONF_CALC_BODY_METRICS, default=False): cv.boolean, } ), ) + + async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None): + self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + if TYPE_CHECKING: + assert self._entry is not None + + if user_input is not None: + if user_input[CONF_CALC_BODY_METRICS]: + self.context[CONF_UNIT_SYSTEM] = user_input[CONF_UNIT_SYSTEM] + return await self.async_step_reconfigure_body_metrics() + + return self.async_update_reload_and_abort( + self._entry, + title=self._entry.title, + reason="reconfigure_successful", + data={CONF_UNIT_SYSTEM: user_input[CONF_UNIT_SYSTEM]}, + ) + + if not (body_metrics_enabled := self._entry.data.get(CONF_CALC_BODY_METRICS)): + body_metrics_enabled = False + + if not (unit_system := self._entry.data.get(CONF_UNIT_SYSTEM)): + unit_system = vol.UNDEFINED + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Required(CONF_UNIT_SYSTEM, default=unit_system): vol.In( + {UnitOfMass.KILOGRAMS: "Metric", UnitOfMass.POUNDS: "Imperial"} + ), + vol.Required( + CONF_CALC_BODY_METRICS, default=body_metrics_enabled + ): cv.boolean, + } + ), + ) + + async def async_step_reconfigure_body_metrics( + self, user_input: dict[str, Any] | None = None + ): + unit = self.context[CONF_UNIT_SYSTEM] + if user_input is not None: + centimeters, feet, inches = self._convert_height_measurements( + unit, user_input + ) + return self.async_update_reload_and_abort( + self._entry, + title=self._entry.title, + reason="reconfigure_successful", + data={ + CONF_UNIT_SYSTEM: unit, + CONF_CALC_BODY_METRICS: True, + CONF_SEX: user_input[CONF_SEX], + CONF_BIRTHDATE: user_input[CONF_BIRTHDATE], + CONF_HEIGHT: centimeters, + CONF_FEET: feet, + CONF_INCHES: inches, + }, + ) + + schema_dict: dict[vol.Required, Any] = ( + self.metric_schema_dict + if unit == UnitOfMass.KILOGRAMS + else self.imperial_schema_dict + ) + + if self._entry.data.get(CONF_CALC_BODY_METRICS): + schema_dict = { + vol.Required( + key.schema, key.msg, self._entry.data[key.schema], key.description + ): value + for key, value in schema_dict.items() + } + + return self.async_show_form( + step_id="reconfigure_body_metrics", + data_schema=vol.Schema(schema_dict), + ) diff --git a/custom_components/etekcity_fitness_scale_ble/const.py b/custom_components/etekcity_fitness_scale_ble/const.py index d0d78ed..5302a7e 100755 --- a/custom_components/etekcity_fitness_scale_ble/const.py +++ b/custom_components/etekcity_fitness_scale_ble/const.py @@ -1,3 +1,11 @@ """Constants for the etekcity_fitness_scale_ble integration.""" DOMAIN = "etekcity_fitness_scale_ble" + +CONF_CALC_BODY_METRICS = "calculate body metrics" +CONF_SEX = "sex" +CONF_HEIGHT = "height" +CONF_BIRTHDATE = "birthdate" + +CONF_FEET = "feet" +CONF_INCHES = "inches" diff --git a/custom_components/etekcity_fitness_scale_ble/coordinator.py b/custom_components/etekcity_fitness_scale_ble/coordinator.py index fe1bd0a..5e8eea3 100644 --- a/custom_components/etekcity_fitness_scale_ble/coordinator.py +++ b/custom_components/etekcity_fitness_scale_ble/coordinator.py @@ -5,20 +5,23 @@ import asyncio import logging from collections.abc import Callable +from datetime import date from etekcity_esf551_ble import ( EtekcitySmartFitnessScale, + EtekcitySmartFitnessScaleWithBodyMetrics, ScaleData, + Sex, WeightUnit, ) + from homeassistant.core import callback _LOGGER = logging.getLogger(__name__) class ScaleDataUpdateCoordinator: - """ - Coordinator to manage data updates for a scale device. + """Coordinator to manage data updates for a scale device. This class handles the communication with the Etekcity Smart Fitness Scale and coordinates updates to the Home Assistant entities. @@ -27,9 +30,10 @@ class ScaleDataUpdateCoordinator: _client: EtekcitySmartFitnessScale = None _display_unit: WeightUnit = None + body_metrics_enabled: bool = False + def __init__(self, address: str) -> None: - """ - Initialize the ScaleDataUpdateCoordinator. + """Initialize the ScaleDataUpdateCoordinator. Args: address (str): The Bluetooth address of the scale. @@ -37,6 +41,7 @@ def __init__(self, address: str) -> None: """ self.address = address self._lock = asyncio.Lock() + self._listeners: dict[Callable[[], None], Callable[[ScaleData], None]] = {} def set_display_unit(self, unit: WeightUnit) -> None: """Set the display unit for the scale.""" @@ -45,31 +50,43 @@ def set_display_unit(self, unit: WeightUnit) -> None: if self._client: self._client.display_unit = unit + async def _async_start(self) -> None: + if self._client: + _LOGGER.debug("Stopping existing client") + await self._client.async_stop() + + if self.body_metrics_enabled: + _LOGGER.debug( + "Initializing new EtekcitySmartFitnessScaleWithBodyMetrics client" + ) + self._client = EtekcitySmartFitnessScaleWithBodyMetrics( + self.address, + self.update_listeners, + self._sex, + self._birthdate, + self._height_m, + self._display_unit, + ) + else: + _LOGGER.debug("Initializing new EtekcitySmartFitnessScale client") + self._client = EtekcitySmartFitnessScale( + self.address, self.update_listeners, self._display_unit + ) + await self._client.async_start() + @callback - async def async_start(self, update_callback: Callable[[ScaleData], None]) -> None: - """ - Start the coordinator and initialize the scale client. + async def async_start(self) -> None: + """Start the coordinator and initialize the scale client. This method sets up the EtekcitySmartFitnessScale client and starts listening for updates from the scale. - Args: - update_callback (Callable[[ScaleData], None]): A callback function - that will be called when new data is received from the scale. - """ _LOGGER.debug( "Starting ScaleDataUpdateCoordinator for address: %s", self.address ) async with self._lock: - if self._client: - _LOGGER.debug("Stopping existing client") - await self._client.async_stop() - _LOGGER.debug("Initializing new EtekcitySmartFitnessScale client") - self._client = EtekcitySmartFitnessScale( - self.address, update_callback, self._display_unit - ) - await self._client.async_start() + await self._async_start() _LOGGER.debug("ScaleDataUpdateCoordinator started successfully") @callback @@ -83,3 +100,46 @@ async def async_stop(self) -> None: await self._client.async_stop() self._client = None _LOGGER.debug("ScaleDataUpdateCoordinator stopped successfully") + + @callback + def add_listener( + self, update_callback: Callable[[ScaleData], None] + ) -> Callable[[], None]: + """Listen for data updates.""" + + @callback + def remove_listener() -> None: + """Remove update listener.""" + self._listeners.pop(remove_listener) + + self._listeners[remove_listener] = update_callback + return remove_listener + + @callback + def update_listeners(self, data: ScaleData) -> None: + """Update all registered listeners.""" + for update_callback in list(self._listeners.values()): + update_callback(data) + + async def enable_body_metrics( + self, sex: Sex, birthdate: date, height_m: float + ) -> None: + async with self._lock: + self.body_metrics_enabled = True + self._sex = sex + self._birthdate = birthdate + self._height_m = height_m + + if self._client: + await self._async_start() + + async def disable_body_metrics(self) -> None: + async with self._lock: + if self.body_metrics_enabled: + self.body_metrics_enabled = False + self._sex = None + self._birthdate = None + self._height_m = None + + if self._client: + await self._async_start() diff --git a/custom_components/etekcity_fitness_scale_ble/manifest.json b/custom_components/etekcity_fitness_scale_ble/manifest.json index c7b6644..ba3c6eb 100755 --- a/custom_components/etekcity_fitness_scale_ble/manifest.json +++ b/custom_components/etekcity_fitness_scale_ble/manifest.json @@ -19,6 +19,6 @@ "integration_type": "device", "iot_class": "local_push", "issue_tracker": "https://github.com/ronnnnnnnnnnnnn/etekcity_fitness_scale_ble/issues", - "requirements": ["etekcity_esf551_ble==0.1.7"], - "version": "0.1.0" + "requirements": ["etekcity_esf551_ble==0.2.1"], + "version": "0.2.1" } diff --git a/custom_components/etekcity_fitness_scale_ble/sensor.py b/custom_components/etekcity_fitness_scale_ble/sensor.py index a84e975..8aa6738 100755 --- a/custom_components/etekcity_fitness_scale_ble/sensor.py +++ b/custom_components/etekcity_fitness_scale_ble/sensor.py @@ -1,12 +1,13 @@ """Support for Etekcity Fitness Scale BLE sensors.""" -from __future__ import annotations - import logging from dataclasses import dataclass +from datetime import date from typing import Any, Self -from etekcity_esf551_ble import WEIGHT_KEY, WeightUnit +from etekcity_esf551_ble import IMPEDANCE_KEY, WEIGHT_KEY, Sex, WeightUnit +from sensor_state_data import Units + from homeassistant import config_entries from homeassistant.components.sensor import ( RestoreSensor, @@ -14,39 +15,93 @@ SensorEntityDescription, SensorExtraStoredData, SensorStateClass, + async_update_suggested_units, ) -from homeassistant.const import UnitOfMass -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import CONF_UNIT_SYSTEM, UnitOfMass +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import ( - CONNECTION_BLUETOOTH, - DeviceInfo, -) +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import UNDEFINED -from sensor_state_data import DeviceClass, Units -from .const import DOMAIN +from .const import CONF_BIRTHDATE, CONF_CALC_BODY_METRICS, CONF_HEIGHT, CONF_SEX, DOMAIN from .coordinator import ScaleData, ScaleDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -SENSOR_DESCRIPTIONS = { - # Impedance sensor (ohm) - (DeviceClass.IMPEDANCE, Units.OHM): SensorEntityDescription( - key=f"{DeviceClass.IMPEDANCE}_{Units.OHM}", - icon="mdi:omega", - native_unit_of_measurement=Units.OHM, +SENSOR_DESCRIPTIONS = [ + SensorEntityDescription( + key="body_mass_index", + icon="mdi:human-male-height-variant", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="body_fat_percentage", + icon="mdi:human-handsdown", + native_unit_of_measurement=Units.PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - # Mass sensor (kg) - (DeviceClass.MASS, Units.MASS_KILOGRAMS): SensorEntityDescription( - key=f"{DeviceClass.MASS}_{Units.MASS_KILOGRAMS}", + SensorEntityDescription( + key="fat_free_weight", + icon="mdi:run", device_class=SensorDeviceClass.WEIGHT, native_unit_of_measurement=UnitOfMass.KILOGRAMS, state_class=SensorStateClass.MEASUREMENT, ), -} + SensorEntityDescription( + key="subcutaneous_fat_percentage", + icon="mdi:human-handsdown", + native_unit_of_measurement=Units.PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="visceral_fat_value", + icon="mdi:human-handsdown", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="body_water_percentage", + icon="mdi:water-percent", + native_unit_of_measurement=Units.PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="basal_metabolic_rate", + icon="mdi:fire", + native_unit_of_measurement="cal", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="skeletal_muscle_percentage", + icon="mdi:weight-lifter", + native_unit_of_measurement=Units.PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="muscle_mass", + icon="mdi:weight-lifter", + device_class=SensorDeviceClass.WEIGHT, + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="bone_mass", + icon="mdi:bone", + device_class=SensorDeviceClass.WEIGHT, + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="protein_percentage", + icon="mdi:egg-fried", + native_unit_of_measurement=Units.PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="metabolic_age", + icon="mdi:human-walker", + state_class=SensorStateClass.MEASUREMENT, + ), +] async def async_setup_entry( @@ -57,37 +112,169 @@ async def async_setup_entry( """Set up the scale sensors.""" _LOGGER.debug("Setting up scale sensors for entry: %s", entry.entry_id) address = entry.unique_id - sensors_mapping = SENSOR_DESCRIPTIONS.copy() - coordinator = hass.data[DOMAIN][entry.entry_id] - - entity = ScaleSensor( - entry.title, - address, - coordinator, - sensors_mapping[(DeviceClass.MASS, Units.MASS_KILOGRAMS)], + coordinator: ScaleDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities = [ + ScaleWeightSensor( + entry.title, + address, + coordinator, + SensorEntityDescription( + key=WEIGHT_KEY, + icon="mdi:human-handsdown", + device_class=SensorDeviceClass.WEIGHT, + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + ScaleSensor( + entry.title, + address, + coordinator, + SensorEntityDescription( + key=IMPEDANCE_KEY, + icon="mdi:omega", + native_unit_of_measurement=Units.OHM, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + ] + + if entry.data.get(CONF_CALC_BODY_METRICS): + sex: Sex = Sex.Male if entry.data.get(CONF_SEX) == "Male" else Sex.Female + + await coordinator.enable_body_metrics( + sex, + date.fromisoformat(entry.data.get(CONF_BIRTHDATE)), + entry.data.get(CONF_HEIGHT) / 100, + ) + entities += [ + ScaleSensor(entry.title, address, coordinator, desc) + for desc in SENSOR_DESCRIPTIONS + ] + + def _update_unit(sensor: ScaleSensor, unit: str) -> ScaleSensor: + if sensor._attr_device_class == SensorDeviceClass.WEIGHT: + sensor._attr_suggested_unit_of_measurement = unit + return sensor + + display_unit: UnitOfMass = entry.data.get(CONF_UNIT_SYSTEM) + coordinator.set_display_unit( + WeightUnit.KG if display_unit == UnitOfMass.KILOGRAMS else WeightUnit.LB ) - - async_add_entities([entity]) + entities = list( + map( + lambda sensor: _update_unit(sensor, display_unit), + entities, + ) + ) + async_add_entities(entities) + async_update_suggested_units(hass) + await coordinator.async_start() _LOGGER.debug("Scale sensors setup completed for entry: %s", entry.entry_id) +class ScaleSensor(RestoreSensor): + """Base sensor implementation for Etekcity scale measurements.""" + + _attr_should_poll = False + _attr_has_entity_name = True + _attr_available = False + + def __init__( + self, + name: str, + address: str, + coordinator: ScaleDataUpdateCoordinator, + entity_description: SensorEntityDescription, + ) -> None: + """Initialize the scale sensor. + + Args: + name: The name of the sensor. + address: The Bluetooth address of the scale. + coordinator: The data update coordinator for the scale. + entity_description: Description of the sensor entity. + + """ + self.entity_description = entity_description + self._attr_device_class = entity_description.device_class + self._attr_state_class = entity_description.state_class + self._attr_native_unit_of_measurement = ( + entity_description.native_unit_of_measurement + ) + self._attr_icon = entity_description.icon + + self._attr_name = f"{entity_description.key.replace("_", " ").title()}" + + self._attr_unique_id = f"{name}_{entity_description.key}" + + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, address)}, + name=name, + manufacturer="Etekcity", + ) + self._coordinator = coordinator + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + + _LOGGER.debug("Adding sensor to Home Assistant: %s", self.entity_id) + await super().async_added_to_hass() + + self._attr_available = await self.async_restore_data() + + self.async_on_remove(self._coordinator.add_listener(self.handle_update)) + _LOGGER.info("Sensor added to Home Assistant: %s", self.entity_id) + + async def async_restore_data(self) -> bool: + """Restore last state from storage.""" + if last_state := await self.async_get_last_sensor_data(): + _LOGGER.debug("Restoring previous state for sensor: %s", self.entity_id) + self._attr_native_value = last_state.native_value + return True + return False + + def handle_update( + self, + data: ScaleData, + ) -> None: + """Handle updated data from the scale. + + This method is called when new data is received from the scale. + It updates the sensor's state and triggers a state update in Home Assistant. + + Args: + data: The new scale data. + + """ + if measurement := data.measurements.get(self.entity_description.key): + _LOGGER.debug( + "Received update for sensor %s: %s", + self.entity_id, + measurement, + ) + self._attr_available = True + self._attr_native_value = measurement + + self.async_write_ha_state() + _LOGGER.debug("Sensor %s updated successfully", self.entity_id) + + HW_VERSION_KEY = "hw_version" SW_VERSION_KEY = "sw_version" -DISPLAY_UNIT_KEY = "display_unit" @dataclass -class ScaleSensorExtraStoredData(SensorExtraStoredData): +class ScaleWeightSensorExtraStoredData(SensorExtraStoredData): """Object to hold extra stored data for the scale sensor.""" - display_unit: str hw_version: str sw_version: str def as_dict(self) -> dict[str, Any]: """Return a dict representation of the scale sensor data.""" data = super().as_dict() - data[DISPLAY_UNIT_KEY] = self.display_unit data[HW_VERSION_KEY] = self.hw_version data[SW_VERSION_KEY] = self.sw_version @@ -100,10 +287,6 @@ def from_dict(cls, restored: dict[str, Any]) -> Self | None: if extra is None: return None - display_unit: str = restored.get(DISPLAY_UNIT_KEY) - if not display_unit: - display_unit = UNDEFINED - restored.setdefault("") hw_version: str = restored.get(HW_VERSION_KEY) sw_version: str = restored.get(SW_VERSION_KEY) @@ -111,21 +294,13 @@ def from_dict(cls, restored: dict[str, Any]) -> Self | None: return cls( extra.native_value, extra.native_unit_of_measurement, - display_unit, hw_version, sw_version, ) -class ScaleSensor(RestoreSensor): - """Representation of a sensor for the Etekcity scale.""" - - _attr_should_poll = False - _attr_has_entity_name = True - _attr_available = False - _attr_device_class = SensorDeviceClass.WEIGHT - _attr_state_class = SensorStateClass.MEASUREMENT - _attr_native_unit_of_measurement = UnitOfMass.KILOGRAMS +class ScaleWeightSensor(ScaleSensor): + """Representation of a weight sensor for the Etekcity scale.""" def __init__( self, @@ -134,43 +309,17 @@ def __init__( coordinator: ScaleDataUpdateCoordinator, entity_description: SensorEntityDescription, ) -> None: - """ - Initialize the scale sensor. - - Args: - name: The name of the sensor. - address: The Bluetooth address of the scale. - coordinator: The data update coordinator for the scale. - entity_description: Description of the sensor entity. - - """ - self.entity_description = entity_description - - title = f"{name} {address}" - - self._attr_unique_id = f"{title}_{entity_description.key}" - self._id = address - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_BLUETOOTH, address)}, - name=name, - manufacturer="Etekcity", - ) - self._coordinator = coordinator - - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - - _LOGGER.debug("Adding sensor to Home Assistant: %s", self.entity_id) - await super().async_added_to_hass() + super().__init__(name, address, coordinator, entity_description) + async def async_restore_data(self) -> bool: + """Restore last state from storage.""" if last_state := await self.async_get_last_sensor_data(): _LOGGER.debug("Restoring previous state for sensor: %s", self.entity_id) self._attr_native_value = last_state.native_value self._attr_native_unit_of_measurement = ( last_state.native_unit_of_measurement ) - self._sensor_option_unit_of_measurement = last_state.display_unit address = self._id device_registry = dr.async_get(self.hass) @@ -182,75 +331,27 @@ async def async_added_to_hass(self) -> None: or device_entry.sw_version != last_state.sw_version ): hw_version = last_state.hw_version - if hw_version is None or hw_version == "": + if hw_version == None or hw_version == "": hw_version = device_entry.hw_version sw_version = last_state.sw_version - if sw_version is None or sw_version == "": + if sw_version == None or sw_version == "": sw_version = device_entry.sw_version device_registry.async_update_device( - device_entry.id, - hw_version=hw_version, - sw_version=sw_version, + device_entry.id, hw_version=hw_version, sw_version=sw_version ) self._attr_device_info.update( {HW_VERSION_KEY: hw_version, SW_VERSION_KEY: sw_version} ) - self._attr_available = True - - await self._coordinator.async_start(self.handle_update) - _LOGGER.info("Sensor added to Home Assistant: %s", self.entity_id) - - @callback - def _async_read_entity_options(self) -> None: - _LOGGER.debug("Reading entity options for sensor: %s", self.entity_id) - previous_unit = self._sensor_option_unit_of_measurement - super()._async_read_entity_options() - if self._sensor_option_unit_of_measurement != previous_unit: - match self._sensor_option_unit_of_measurement: - case "kg" | "g" | "mg" | "µg": - self._coordinator.set_display_unit(WeightUnit.KG) - case "lb" | "oz": - self._coordinator.set_display_unit(WeightUnit.LB) - case "st": - self._coordinator.set_display_unit(WeightUnit.ST) - case _: - _LOGGER.warning("Unknown unit of measurement") + return True + return False def handle_update( self, data: ScaleData, ) -> None: - """ - Handle updated data from the scale. - - This method is called when new data is received from the scale. - It updates the sensor's state and triggers a state update in HA. - - Args: - data: The new scale data. - - """ - _LOGGER.debug( - "Received update for sensor %s: %s", - self.entity_id, - data.measurements[WEIGHT_KEY], - ) - self._attr_available = True - self._attr_native_value = data.measurements[WEIGHT_KEY] - - if ( - self._sensor_option_unit_of_measurement is None - or self._sensor_option_unit_of_measurement == UNDEFINED - ): - match data.display_unit: - case WeightUnit.KG: - self._sensor_option_unit_of_measurement = UnitOfMass.KILOGRAMS - case WeightUnit.LB: - self._sensor_option_unit_of_measurement = UnitOfMass.POUNDS - case WeightUnit.ST: - self._sensor_option_unit_of_measurement = UnitOfMass.STONES + """Handle updated data from the scale.""" address = self._id device_registry = dr.async_get(self.hass) @@ -262,11 +363,11 @@ def handle_update( or device_entry.sw_version != data.sw_version ): hw_version = data.hw_version - if hw_version is None or hw_version == "": + if hw_version == None or hw_version == "": hw_version = device_entry.hw_version sw_version = data.sw_version - if sw_version is None or sw_version == "": + if sw_version == None or sw_version == "": sw_version = device_entry.sw_version device_registry.async_update_device( @@ -276,34 +377,33 @@ def handle_update( {HW_VERSION_KEY: hw_version, SW_VERSION_KEY: sw_version} ) - self.async_write_ha_state() - _LOGGER.debug("Sensor %s updated successfully", self.entity_id) + super().handle_update(data) @property def extra_state_attributes(self): """Return the state attributes of the sensor.""" return { - DISPLAY_UNIT_KEY: self._sensor_option_unit_of_measurement, HW_VERSION_KEY: self._attr_device_info.get(HW_VERSION_KEY), SW_VERSION_KEY: self._attr_device_info.get(SW_VERSION_KEY), } @property - def extra_restore_state_data(self) -> ScaleSensorExtraStoredData: + def extra_restore_state_data(self) -> ScaleWeightSensorExtraStoredData: """Return sensor specific state data to be restored.""" - return ScaleSensorExtraStoredData( + return ScaleWeightSensorExtraStoredData( self.native_value, self.native_unit_of_measurement, - self._sensor_option_unit_of_measurement, self._attr_device_info.get(HW_VERSION_KEY), self._attr_device_info.get(SW_VERSION_KEY), ) async def async_get_last_sensor_data( self, - ) -> ScaleSensorExtraStoredData | None: + ) -> ScaleWeightSensorExtraStoredData | None: """Restore Scale Sensor Extra Stored Data.""" if (restored_last_extra_data := await self.async_get_last_extra_data()) is None: return None - return ScaleSensorExtraStoredData.from_dict(restored_last_extra_data.as_dict()) + return ScaleWeightSensorExtraStoredData.from_dict( + restored_last_extra_data.as_dict() + ) diff --git a/custom_components/etekcity_fitness_scale_ble/strings.json b/custom_components/etekcity_fitness_scale_ble/strings.json index f1e0ef0..f122337 100644 --- a/custom_components/etekcity_fitness_scale_ble/strings.json +++ b/custom_components/etekcity_fitness_scale_ble/strings.json @@ -1,15 +1,44 @@ { "config": { - "flow_title": "{name}", + "flow_title": "{name} ({address})", "step": { "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:common::config_flow::data::device%]" + "address": "[%key:common::config_flow::data::device%]", + "unit_system": "Unit System", + "calculate body metrics": "Calculate body composition metrics" } }, "bluetooth_confirm": { - "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + "description": "Configure device options", + "data": { + "unit_system": "Unit System", + "calculate body metrics": "Calculate body composition metrics" + } + }, + "body_metrics": { + "description": "Fill in your sex, date of birth and height", + "data": { + "sex": "Sex", + "height": "Height", + "birthdate": "Date of Birth" + } + }, + "reconfigure": { + "description": "Configure device options", + "data": { + "unit_system": "Unit System", + "calculate body metrics": "Calculate body composition metrics" + } + }, + "reconfigure_body_metrics": { + "description": "Fill in your sex, date of birth and height", + "data": { + "sex": "Sex", + "height": "Height", + "birthdate": "Date of Birth" + } } }, "error": { @@ -19,7 +48,8 @@ "abort": { "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } } } diff --git a/custom_components/etekcity_fitness_scale_ble/translations/en.json b/custom_components/etekcity_fitness_scale_ble/translations/en.json index 0ab7e02..65dc817 100644 --- a/custom_components/etekcity_fitness_scale_ble/translations/en.json +++ b/custom_components/etekcity_fitness_scale_ble/translations/en.json @@ -3,20 +3,50 @@ "abort": { "already_configured": "Device is already configured", "already_in_progress": "Configuration flow is already in progress", - "no_devices_found": "No devices found on the network" + "no_devices_found": "No devices found on the network", + "reconfigure_successful": "Re-configuration was successful" }, "error": { "cannot_connect": "Failed to connect", "unknown": "Unexpected error" }, - "flow_title": "{name}", + "flow_title": "{name} ({address})", "step": { "bluetooth_confirm": { - "description": "Do you want to set up {name}?" + "data": { + "calculate body metrics": "Calculate body composition metrics", + "unit_system": "Unit System" + }, + "description": "Configure device options" + }, + "body_metrics": { + "data": { + "birthdate": "Date of Birth", + "height": "Height", + "sex": "Sex" + }, + "description": "Fill in your sex, date of birth and height" + }, + "reconfigure": { + "data": { + "calculate body metrics": "Calculate body composition metrics", + "unit_system": "Unit System" + }, + "description": "Configure device options" + }, + "reconfigure_body_metrics": { + "data": { + "birthdate": "Date of Birth", + "height": "Height", + "sex": "Sex" + }, + "description": "Fill in your sex, date of birth and height" }, "user": { "data": { - "address": "Device" + "address": "Device", + "calculate body metrics": "Calculate body composition metrics", + "unit_system": "Unit System" }, "description": "Choose a device to set up" } diff --git a/requirements.txt b/requirements.txt index bfc57f0..edfaadf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -etekcity_esf551_ble==0.1.7 +etekcity_esf551_ble==0.2.1 ruff==0.6.2 \ No newline at end of file