From 056ac4d3bd3a6b7abf34a0bb52f629523ee6f422 Mon Sep 17 00:00:00 2001 From: bkbilly Date: Wed, 13 Mar 2024 01:55:49 +0200 Subject: [PATCH] first commit --- .github/workflows/hassfest.yaml | 14 ++ .github/workflows/validate.yaml | 18 +++ LICENSE.txt | 10 ++ README.md | 24 +++ custom_components/medisanabp_ble/__init__.py | 96 +++++++++++ .../medisanabp_ble/config_flow.py | 95 +++++++++++ custom_components/medisanabp_ble/const.py | 3 + custom_components/medisanabp_ble/device.py | 16 ++ .../medisanabp_ble/manifest.json | 18 +++ .../medisanabp_ble/medisana_bp/__init__.py | 30 ++++ .../medisanabp_ble/medisana_bp/const.py | 5 + .../medisanabp_ble/medisana_bp/parser.py | 152 ++++++++++++++++++ .../medisanabp_ble/medisana_bp/py.typed | 0 custom_components/medisanabp_ble/sensor.py | 149 +++++++++++++++++ custom_components/medisanabp_ble/strings.json | 22 +++ hacs.json | 5 + 16 files changed, 657 insertions(+) create mode 100644 .github/workflows/hassfest.yaml create mode 100644 .github/workflows/validate.yaml create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 custom_components/medisanabp_ble/__init__.py create mode 100644 custom_components/medisanabp_ble/config_flow.py create mode 100644 custom_components/medisanabp_ble/const.py create mode 100644 custom_components/medisanabp_ble/device.py create mode 100644 custom_components/medisanabp_ble/manifest.json create mode 100644 custom_components/medisanabp_ble/medisana_bp/__init__.py create mode 100644 custom_components/medisanabp_ble/medisana_bp/const.py create mode 100644 custom_components/medisanabp_ble/medisana_bp/parser.py create mode 100644 custom_components/medisanabp_ble/medisana_bp/py.typed create mode 100644 custom_components/medisanabp_ble/sensor.py create mode 100644 custom_components/medisanabp_ble/strings.json create mode 100644 hacs.json diff --git a/.github/workflows/hassfest.yaml b/.github/workflows/hassfest.yaml new file mode 100644 index 0000000..07d1dda --- /dev/null +++ b/.github/workflows/hassfest.yaml @@ -0,0 +1,14 @@ +name: Validate with hassfest + +on: + push: + pull_request: + schedule: + - cron: "0 0 * * *" + +jobs: + validate: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v3" + - uses: home-assistant/actions/hassfest@master diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml new file mode 100644 index 0000000..c422ec3 --- /dev/null +++ b/.github/workflows/validate.yaml @@ -0,0 +1,18 @@ +name: Validate + +on: + push: + pull_request: + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +jobs: + validate-hacs: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v3" + - name: HACS validation + uses: "hacs/action@main" + with: + category: "integration" diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..aa06fe9 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,10 @@ +MIT License + +Copyright (c) 2024 Vasilis Koulis + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e191868 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +[![GitHub Release](https://img.shields.io/github/release/bkbilly/medisanabp_ble.svg?style=flat-square)](https://github.com/bkbilly/medisanabp_ble/releases) +[![License](https://img.shields.io/github/license/bkbilly/medisanabp_ble.svg?style=flat-square)](LICENSE) +[![hacs](https://img.shields.io/badge/HACS-default-orange.svg?style=flat-square)](https://hacs.xyz) + + +# Medisana Blood Pressure BLE +Integrates Bluetooth LE (https://www.medisana.com/en/Health-control/Blood-pressure-monitor/) to Home Assistant using active connection to get infromation from the sensors. + +Exposes the following sensors: + - Battery + - Diastolic pressure + - Systolic pressure + - Pulses + - Measured date + +## Installation + +Easiest install is via [HACS](https://hacs.xyz/): + +[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=bkbilly&repository=medisanabp_ble&category=integration) + +`HACS -> Explore & Add Repositories -> Medisana Blood Pressure BLE` + +The device will be autodiscovered once the data are received by any bluetooth proxy. diff --git a/custom_components/medisanabp_ble/__init__.py b/custom_components/medisanabp_ble/__init__.py new file mode 100644 index 0000000..829869e --- /dev/null +++ b/custom_components/medisanabp_ble/__init__.py @@ -0,0 +1,96 @@ +"""The MedisanaBP integration.""" + +from __future__ import annotations + +import logging + + +from homeassistant.components.bluetooth import ( + BluetoothScanningMode, + BluetoothServiceInfoBleak, + async_ble_device_from_address, +) +from homeassistant.components.bluetooth.active_update_processor import ( + ActiveBluetoothProcessorCoordinator, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import CoreState, HomeAssistant + +from .medisana_bp import MedisanaBPBluetoothDeviceData, SensorUpdate +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up MedisanaBP BLE device from a config entry.""" + address = entry.unique_id + assert address is not None + data = MedisanaBPBluetoothDeviceData() + + def _needs_poll( + service_info: BluetoothServiceInfoBleak, last_poll: float | None + ) -> bool: + # Only poll if hass is running, we need to poll, + # and we actually have a way to connect to the device + return ( + hass.state is CoreState.running + and data.poll_needed(service_info, last_poll) + and bool( + async_ble_device_from_address( + hass, service_info.device.address, connectable=True + ) + ) + ) + + async def _async_poll(service_info: BluetoothServiceInfoBleak) -> SensorUpdate: + # BluetoothServiceInfoBleak is defined in HA, otherwise would just pass it + # directly to the elissabp code + # Make sure the device we have is one that we can connect with + # in case its coming from a passive scanner + if service_info.connectable: + connectable_device = service_info.device + elif device := async_ble_device_from_address( + hass, service_info.device.address, True + ): + connectable_device = device + else: + # We have no bluetooth controller that is in range of + # the device to poll it + raise RuntimeError( + f"No connectable device found for {service_info.device.address}" + ) + return await data.async_poll(connectable_device) + + coordinator = hass.data.setdefault(DOMAIN, {})[ + entry.entry_id + ] = ActiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.PASSIVE, + update_method=data.update, + needs_poll_method=_needs_poll, + poll_method=_async_poll, + # We will take advertisements from non-connectable devices + # since we will trade the BLEDevice for a connectable one + # if we need to poll it + connectable=False, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload( + # only start after all platforms have had a chance to subscribe + coordinator.async_start() + ) + return True + + +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) + + return unload_ok \ No newline at end of file diff --git a/custom_components/medisanabp_ble/config_flow.py b/custom_components/medisanabp_ble/config_flow.py new file mode 100644 index 0000000..8268513 --- /dev/null +++ b/custom_components/medisanabp_ble/config_flow.py @@ -0,0 +1,95 @@ +"""Config flow for MedisanaBP BLE integration.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult +from homeassistant.const import CONF_ADDRESS + +from .medisana_bp import MedisanaBPBluetoothDeviceData +from .const import DOMAIN + + +class MedisanaBPConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for MedisanaBP.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfoBleak | None = None + self._discovered_device: MedisanaBPBluetoothDeviceData | None = None + self._discovered_devices: dict[str, str] = {} + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> FlowResult: + """Handle the bluetooth discovery step.""" + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + device = MedisanaBPBluetoothDeviceData() + if not device.supported(discovery_info): + return self.async_abort(reason="not_supported") + self._discovery_info = discovery_info + self._discovered_device = device + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + assert self._discovered_device is not None + device = self._discovered_device + assert self._discovery_info is not None + discovery_info = self._discovery_info + title = device.title or device.get_device_name() or discovery_info.name + if user_input is not None: + return self.async_create_entry(title=title, data={}) + + self._set_confirm_only() + placeholders = {"name": title} + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="bluetooth_confirm", description_placeholders=placeholders + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step to pick discovered device.""" + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self._discovered_devices[address], data={} + ) + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass, False): + address = discovery_info.address + if address in current_addresses or address in self._discovered_devices: + continue + device = MedisanaBPBluetoothDeviceData() + if device.supported(discovery_info): + self._discovered_devices[address] = ( + device.title or device.get_device_name() or discovery_info.name + ) + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)} + ), + ) \ No newline at end of file diff --git a/custom_components/medisanabp_ble/const.py b/custom_components/medisanabp_ble/const.py new file mode 100644 index 0000000..e8978fd --- /dev/null +++ b/custom_components/medisanabp_ble/const.py @@ -0,0 +1,3 @@ +"""Constants for MedisanaBP BLE.""" + +DOMAIN = "medisanabp_ble" diff --git a/custom_components/medisanabp_ble/device.py b/custom_components/medisanabp_ble/device.py new file mode 100644 index 0000000..331a237 --- /dev/null +++ b/custom_components/medisanabp_ble/device.py @@ -0,0 +1,16 @@ +"""Constants for MedisanaBP BLE.""" + +from __future__ import annotations + +from .medisana_bp import DeviceKey + +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothEntityKey, +) + + +def device_key_to_bluetooth_entity_key( + device_key: DeviceKey, +) -> PassiveBluetoothEntityKey: + """Convert a device key to an entity key.""" + return PassiveBluetoothEntityKey(device_key.key, device_key.device_id) diff --git a/custom_components/medisanabp_ble/manifest.json b/custom_components/medisanabp_ble/manifest.json new file mode 100644 index 0000000..800dc62 --- /dev/null +++ b/custom_components/medisanabp_ble/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "medisanabp_ble", + "name": "Medisana Blood Pressure BLE", + "config_flow": true, + "documentation": "https://github.com/bkbilly/medisanabp_ble", + "issue_tracker": "https://github.com/bkbilly/medisanabp_ble/issues", + "bluetooth": [ + { + "manufacturer_id": 18498, + "connectable": true + } + ], + "codeowners": ["@bkbilly"], + "iot_class": "local_push", + "dependencies": ["bluetooth_adapters"], + "requirements": [], + "version": "0.1.0" +} diff --git a/custom_components/medisanabp_ble/medisana_bp/__init__.py b/custom_components/medisanabp_ble/medisana_bp/__init__.py new file mode 100644 index 0000000..b8e6d73 --- /dev/null +++ b/custom_components/medisanabp_ble/medisana_bp/__init__.py @@ -0,0 +1,30 @@ +"""Parser for MedisanaBP BLE advertisements""" +from __future__ import annotations + +from sensor_state_data import ( + BinarySensorDeviceClass, + BinarySensorValue, + DeviceKey, + SensorDescription, + SensorDeviceClass, + SensorDeviceInfo, + SensorUpdate, + SensorValue, + Units, +) + +from .parser import MedisanaBPBluetoothDeviceData, MedisanaBPSensor + +__version__ = "0.1.0" + +__all__ = [ + "MedisanaBPSensor", + "MedisanaBPBluetoothDeviceData", + "BinarySensorDeviceClass", + "DeviceKey", + "SensorUpdate", + "SensorDeviceClass", + "SensorDeviceInfo", + "SensorValue", + "Units", +] diff --git a/custom_components/medisanabp_ble/medisana_bp/const.py b/custom_components/medisanabp_ble/medisana_bp/const.py new file mode 100644 index 0000000..fbbdc0e --- /dev/null +++ b/custom_components/medisanabp_ble/medisana_bp/const.py @@ -0,0 +1,5 @@ +"""Constants for MedisanaBP BLE parser""" + +CHARACTERISTIC_BLOOD_PRESSURE = "00002A35-0000-1000-8000-00805f9b34fb" +CHARACTERISTIC_BATTERY = "00002A19-0000-1000-8000-00805F9B34FB" +UPDATE_INTERVAL = 10 diff --git a/custom_components/medisanabp_ble/medisana_bp/parser.py b/custom_components/medisanabp_ble/medisana_bp/parser.py new file mode 100644 index 0000000..25b23a1 --- /dev/null +++ b/custom_components/medisanabp_ble/medisana_bp/parser.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import logging +import asyncio +from datetime import datetime, timezone + +from bleak import BLEDevice +from bleak_retry_connector import ( + BleakClientWithServiceCache, + establish_connection, + retry_bluetooth_connection_error, +) +from bluetooth_data_tools import short_address +from bluetooth_sensor_state_data import BluetoothData +from home_assistant_bluetooth import BluetoothServiceInfo +from sensor_state_data import SensorDeviceClass, SensorUpdate, Units +from sensor_state_data.enum import StrEnum + +from .const import ( + CHARACTERISTIC_BLOOD_PRESSURE, + CHARACTERISTIC_BATTERY, + UPDATE_INTERVAL, +) + +_LOGGER = logging.getLogger(__name__) + + +class MedisanaBPSensor(StrEnum): + + SYSTOLIC = "systolic" + DIASTOLIC = "diastolic" + PULSE = "pulse" + SIGNAL_STRENGTH = "signal_strength" + BATTERY_PERCENT = "battery_percent" + TIMESTAMP = "timestamp" + + +class MedisanaBPBluetoothDeviceData(BluetoothData): + """Data for MedisanaBP BLE sensors.""" + + def __init__(self) -> None: + super().__init__() + self._event = asyncio.Event() + + def _start_update(self, service_info: BluetoothServiceInfo) -> None: + """Update from BLE advertisement data.""" + _LOGGER.debug("Parsing MedisanaBP BLE advertisement data: %s", service_info) + self.set_device_manufacturer("Medisana") + self.set_device_type("Blood Pressure Measurement") + name = f"{service_info.name} {short_address(service_info.address)}" + self.set_device_name(name) + self.set_title(name) + + def poll_needed( + self, service_info: BluetoothServiceInfo, last_poll: float | None + ) -> bool: + """ + This is called every time we get a service_info for a device. It means the + device is working and online. + """ + return not last_poll or last_poll > UPDATE_INTERVAL + + @retry_bluetooth_connection_error() + def notification_handler(self, _, data) -> None: + """Helper for command events""" + syst = data[2] * 256 + data[1] + diast = data[4] * 256 + data[3] + arter = data[6] * 256 + data[5] + dyear = data[8] * 256 + data[7] + dmonth = data[9] + dday = data[10] + dhour = data[11] + dminu = data[12] + puls = data[15] * 256 + data[14] + user = data[16] + try: + datetime_str = f"{dyear}/{dmonth}/{dday} {dhour}:{dminu:0>2}" + date = datetime.strptime(datetime_str, '%Y/%m/%d %H:%M') + local_timezone = datetime.now(timezone.utc).astimezone().tzinfo + self.update_sensor( + key=str(MedisanaBPSensor.TIMESTAMP), + native_unit_of_measurement=None, + native_value=date.replace(tzinfo=local_timezone), + name="Measured Date", + ) + except: + _LOGGER.error("Can't add Measured Date") + + _LOGGER.info( + "Got data from BPM device (syst: %s, diast: %s, puls: %s)", + syst, diast, puls) + + self.update_sensor( + key=str(MedisanaBPSensor.SYSTOLIC), + native_unit_of_measurement=Units.PRESSURE_MMHG, + native_value=syst, + device_class=SensorDeviceClass.PRESSURE, + name="Systolic", + ) + self.update_sensor( + key=str(MedisanaBPSensor.DIASTOLIC), + native_unit_of_measurement=Units.PRESSURE_MMHG, + native_value=diast, + device_class=SensorDeviceClass.PRESSURE, + name="Diastolic", + ) + self.update_sensor( + key=str(MedisanaBPSensor.PULSE), + native_unit_of_measurement="bpm", + native_value=puls, + name="Pulse", + ) + self._event.set() + return + + async def async_poll(self, ble_device: BLEDevice) -> SensorUpdate: + """ + Poll the device to retrieve any values we can't get from passive listening. + """ + _LOGGER.debug("Connecting to BLE device: %s", ble_device.address) + client = await establish_connection( + BleakClientWithServiceCache, ble_device, ble_device.address + ) + try: + await client.start_notify( + CHARACTERISTIC_BLOOD_PRESSURE, self.notification_handler + ) + except: + _LOGGER.warn("Notify Bleak error") + + battery_char = client.services.get_characteristic(CHARACTERISTIC_BATTERY) + battery_payload = await client.read_gatt_char(battery_char) + self.update_sensor( + key=str(MedisanaBPSensor.BATTERY_PERCENT), + native_unit_of_measurement=Units.PERCENTAGE, + native_value=battery_payload[0], + device_class=SensorDeviceClass.BATTERY, + name="Battery", + ) + + # Wait to see if a callback comes in. + try: + await asyncio.wait_for(self._event.wait(), 15) + except asyncio.TimeoutError: + _LOGGER.warn("Timeout getting command data.") + except: + _LOGGER.warn("Wait For Bleak error") + finally: + await client.stop_notify(CHARACTERISTIC_BLOOD_PRESSURE) + await client.disconnect() + _LOGGER.debug("Disconnected from active bluetooth client") + return self._finish_update() diff --git a/custom_components/medisanabp_ble/medisana_bp/py.typed b/custom_components/medisanabp_ble/medisana_bp/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/medisanabp_ble/sensor.py b/custom_components/medisanabp_ble/sensor.py new file mode 100644 index 0000000..e9ff7ff --- /dev/null +++ b/custom_components/medisanabp_ble/sensor.py @@ -0,0 +1,149 @@ +"""Support for MedisanaBP sensors.""" + +from __future__ import annotations + +from .medisana_bp import MedisanaBPSensor, SensorUpdate + +from homeassistant import config_entries +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothDataProcessor, + PassiveBluetoothDataUpdate, + PassiveBluetoothProcessorCoordinator, + PassiveBluetoothProcessorEntity, +) +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfPressure, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info + +from .device import device_key_to_bluetooth_entity_key +from .const import DOMAIN + + + +SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = { + MedisanaBPSensor.SYSTOLIC: SensorEntityDescription( + key=MedisanaBPSensor.SYSTOLIC, + native_unit_of_measurement=UnitOfPressure.MMHG, + device_class=SensorDeviceClass.PRESSURE, + icon="mdi:water-minus", + ), + MedisanaBPSensor.DIASTOLIC: SensorEntityDescription( + key=MedisanaBPSensor.DIASTOLIC, + native_unit_of_measurement=UnitOfPressure.MMHG, + device_class=SensorDeviceClass.PRESSURE, + icon="mdi:water-plus", + ), + MedisanaBPSensor.PULSE: SensorEntityDescription( + key=MedisanaBPSensor.PULSE, + native_unit_of_measurement="bpm", + icon="mdi:heart-flash", + ), + MedisanaBPSensor.SIGNAL_STRENGTH: SensorEntityDescription( + key=MedisanaBPSensor.SIGNAL_STRENGTH, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + MedisanaBPSensor.BATTERY_PERCENT: SensorEntityDescription( + key=MedisanaBPSensor.BATTERY_PERCENT, + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + MedisanaBPSensor.TIMESTAMP: SensorEntityDescription( + key=MedisanaBPSensor.TIMESTAMP, + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:clock-time-four-outline", + ), + +} + + +def sensor_update_to_bluetooth_data_update( + sensor_update: SensorUpdate, +) -> PassiveBluetoothDataUpdate: + """Convert a sensor update to a bluetooth data update.""" + return PassiveBluetoothDataUpdate( + devices={ + device_id: sensor_device_info_to_hass_device_info(device_info) + for device_id, device_info in sensor_update.devices.items() + }, + entity_descriptions={ + device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ + device_key.key + ] + for device_key in sensor_update.entity_descriptions + }, + entity_data={ + device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value + for device_key, sensor_values in sensor_update.entity_values.items() + }, + entity_names={ + device_key_to_bluetooth_entity_key(device_key): sensor_values.name + for device_key, sensor_values in sensor_update.entity_values.items() + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the MedisanaBP BLE sensors.""" + coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + entry.entry_id + ] + processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) + entry.async_on_unload( + processor.async_add_entities_listener( + MedisanaBPBluetoothSensorEntity, async_add_entities + ) + ) + entry.async_on_unload( + coordinator.async_register_processor(processor, SensorEntityDescription) + ) + + +class MedisanaBPBluetoothSensorEntity( + PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[str | int | None]], + SensorEntity, +): + """Representation of a MedisanaBP sensor.""" + + @property + def native_value(self) -> str | int | None: + """Return the native value.""" + return self.processor.entity_data.get(self.entity_key) + + @property + def available(self) -> bool: + """Return True if entity is available. + + The sensor is only created when the device is seen. + + Since these are sleepy devices which stop broadcasting + when not in use, we can't rely on the last update time + so once we have seen the device we always return True. + """ + return True + + @property + def assumed_state(self) -> bool: + """Return True if the device is no longer broadcasting.""" + return not self.processor.available diff --git a/custom_components/medisanabp_ble/strings.json b/custom_components/medisanabp_ble/strings.json new file mode 100644 index 0000000..f81e1a9 --- /dev/null +++ b/custom_components/medisanabp_ble/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:common::config_flow::data::device%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "not_supported": "Device not supported", + "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%]" + } + } +} \ No newline at end of file diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..afcabc8 --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "Medisana Blood Pressure BLE", + "render_readme": true, + "homeassistant": "2023.11.0" +}