From 39f40011ccebcfa8601b8b6b124a647c7312b12e Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Sat, 10 Sep 2022 04:43:25 +0200 Subject: [PATCH] Add BTHome binary sensors (#78151) --- homeassistant/components/bthome/__init__.py | 2 +- .../components/bthome/binary_sensor.py | 93 ++++++++++++++ homeassistant/components/bthome/manifest.json | 2 +- homeassistant/components/bthome/sensor.py | 4 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bthome/test_binary_sensor.py | 113 ++++++++++++++++++ 7 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/bthome/binary_sensor.py create mode 100644 tests/components/bthome/test_binary_sensor.py diff --git a/homeassistant/components/bthome/__init__.py b/homeassistant/components/bthome/__init__.py index 93ebd7b288f..539aa112a06 100644 --- a/homeassistant/components/bthome/__init__.py +++ b/homeassistant/components/bthome/__init__.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bthome/binary_sensor.py b/homeassistant/components/bthome/binary_sensor.py new file mode 100644 index 00000000000..9a5ffb94afd --- /dev/null +++ b/homeassistant/components/bthome/binary_sensor.py @@ -0,0 +1,93 @@ +"""Support for BTHome binary sensors.""" +from __future__ import annotations + +from typing import Optional + +from bthome_ble import BTHOME_BINARY_SENSORS, SensorUpdate + +from homeassistant import config_entries +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothDataProcessor, + PassiveBluetoothDataUpdate, + PassiveBluetoothProcessorCoordinator, + PassiveBluetoothProcessorEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .device import device_key_to_bluetooth_entity_key, sensor_device_info_to_hass + +BINARY_SENSOR_DESCRIPTIONS = {} +for key in BTHOME_BINARY_SENSORS: + # Not all BTHome device classes are available in Home Assistant + DEV_CLASS: str | None = key + try: + BinarySensorDeviceClass(key) + except ValueError: + DEV_CLASS = None + BINARY_SENSOR_DESCRIPTIONS[key] = BinarySensorEntityDescription( + key=key, + device_class=DEV_CLASS, + ) + + +def sensor_update_to_bluetooth_data_update( + sensor_update: SensorUpdate, +) -> PassiveBluetoothDataUpdate: + """Convert a binary sensor update to a bluetooth data update.""" + return PassiveBluetoothDataUpdate( + devices={ + device_id: sensor_device_info_to_hass(device_info) + for device_id, device_info in sensor_update.devices.items() + }, + entity_descriptions={ + device_key_to_bluetooth_entity_key(device_key): BINARY_SENSOR_DESCRIPTIONS[ + description.device_key.key + ] + for device_key, description in sensor_update.binary_entity_descriptions.items() + }, + entity_data={ + device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value + for device_key, sensor_values in sensor_update.binary_entity_values.items() + }, + entity_names={ + device_key_to_bluetooth_entity_key(device_key): sensor_values.name + for device_key, sensor_values in sensor_update.binary_entity_values.items() + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the BTHome BLE binary 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( + BTHomeBluetoothBinarySensorEntity, async_add_entities + ) + ) + entry.async_on_unload(coordinator.async_register_processor(processor)) + + +class BTHomeBluetoothBinarySensorEntity( + PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[Optional[bool]]], + BinarySensorEntity, +): + """Representation of a BTHome binary sensor.""" + + @property + def is_on(self) -> bool | None: + """Return the native value.""" + return self.processor.entity_data.get(self.entity_key) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 597d52c72e4..5b58581226b 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -13,7 +13,7 @@ "service_data_uuid": "0000181e-0000-1000-8000-00805f9b34fb" } ], - "requirements": ["bthome-ble==1.0.0"], + "requirements": ["bthome-ble==1.2.0"], "dependencies": ["bluetooth"], "codeowners": ["@Ernst79"], "iot_class": "local_push" diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index a0068596b01..cfb516d07b9 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -148,9 +148,9 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, ), # Used for moisture sensor - (None, Units.PERCENTAGE,): SensorEntityDescription( + (DeviceClass.MOISTURE, Units.PERCENTAGE): SensorEntityDescription( key=f"{DeviceClass.MOISTURE}_{Units.PERCENTAGE}", - device_class=None, + device_class=SensorDeviceClass.MOISTURE, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), diff --git a/requirements_all.txt b/requirements_all.txt index 4dc0db398ee..2051f1f3009 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -464,7 +464,7 @@ bsblan==0.5.0 bt_proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==1.0.0 +bthome-ble==1.2.0 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5a92a8f10ac..0be6ec7afed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -365,7 +365,7 @@ brunt==1.2.0 bsblan==0.5.0 # homeassistant.components.bthome -bthome-ble==1.0.0 +bthome-ble==1.2.0 # homeassistant.components.buienradar buienradar==1.0.5 diff --git a/tests/components/bthome/test_binary_sensor.py b/tests/components/bthome/test_binary_sensor.py new file mode 100644 index 00000000000..fc7b128d124 --- /dev/null +++ b/tests/components/bthome/test_binary_sensor.py @@ -0,0 +1,113 @@ +"""Test BTHome binary sensors.""" + +import logging +from unittest.mock import patch + +import pytest + +from homeassistant.components.bluetooth import BluetoothChange +from homeassistant.components.bthome.const import DOMAIN +from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF, STATE_ON + +from . import make_advertisement + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +@pytest.mark.parametrize( + "mac_address, advertisement, bind_key, result", + [ + ( + "A4:C1:38:8D:18:B2", + make_advertisement( + "A4:C1:38:8D:18:B2", + b"\x02\x10\x01", + ), + None, + [ + { + "binary_sensor_entity": "binary_sensor.test_device_18b2_power", + "friendly_name": "Test Device 18B2 Power", + "expected_state": STATE_ON, + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_advertisement( + "A4:C1:38:8D:18:B2", + b"\x02\x11\x00", + ), + None, + [ + { + "binary_sensor_entity": "binary_sensor.test_device_18b2_opening", + "friendly_name": "Test Device 18B2 Opening", + "expected_state": STATE_OFF, + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_advertisement( + "A4:C1:38:8D:18:B2", + b"\x02\x0F\x01", + ), + None, + [ + { + "binary_sensor_entity": "binary_sensor.test_device_18b2_generic", + "friendly_name": "Test Device 18B2 Generic", + "expected_state": STATE_ON, + }, + ], + ), + ], +) +async def test_binary_sensors( + hass, + mac_address, + advertisement, + bind_key, + result, +): + """Test the different binary sensors.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=mac_address, + data={"bindkey": bind_key}, + ) + entry.add_to_hass(hass) + + saved_callback = None + + def _async_register_callback(_hass, _callback, _matcher, _mode): + nonlocal saved_callback + saved_callback = _callback + return lambda: None + + with patch( + "homeassistant.components.bluetooth.update_coordinator.async_register_callback", + _async_register_callback, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + + saved_callback( + advertisement, + BluetoothChange.ADVERTISEMENT, + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == len(result) + for meas in result: + binary_sensor = hass.states.get(meas["binary_sensor_entity"]) + binary_sensor_attr = binary_sensor.attributes + assert binary_sensor.state == meas["expected_state"] + assert binary_sensor_attr[ATTR_FRIENDLY_NAME] == meas["friendly_name"] + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done()