From b72876d369e4320ab5114f70ba75d4000c28add1 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Wed, 9 Nov 2022 15:31:58 +0100 Subject: [PATCH] Add support for BTHome V2 to bthome (#81811) * Add BTHome v2 support * Add new sensor types * Add new sensor types --- homeassistant/components/bthome/manifest.json | 6 +- homeassistant/components/bthome/sensor.py | 88 ++- homeassistant/generated/bluetooth.py | 5 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bthome/__init__.py | 23 +- tests/components/bthome/test_binary_sensor.py | 98 ++- tests/components/bthome/test_sensor.py | 610 +++++++++++++++++- 8 files changed, 790 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 3b4cbe2f4f4..0111f765014 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -11,9 +11,13 @@ { "connectable": false, "service_data_uuid": "0000181e-0000-1000-8000-00805f9b34fb" + }, + { + "connectable": false, + "service_data_uuid": "0000fcd2-0000-1000-8000-00805f9b34fb" } ], - "requirements": ["bthome-ble==1.2.2"], + "requirements": ["bthome-ble==2.2.1"], "dependencies": ["bluetooth"], "codeowners": ["@Ernst79"], "iot_class": "local_push" diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 188bd659c6d..e7757c2e872 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -21,16 +21,20 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, + DEGREE, + ELECTRIC_CURRENT_AMPERE, ELECTRIC_POTENTIAL_VOLT, - ENERGY_KILO_WATT_HOUR, LIGHT_LUX, - MASS_KILOGRAMS, - MASS_POUNDS, PERCENTAGE, - POWER_WATT, - PRESSURE_MBAR, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - TEMP_CELSIUS, + TIME_SECONDS, + UnitOfEnergy, + UnitOfLength, + UnitOfMass, + UnitOfPower, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory @@ -43,7 +47,7 @@ SENSOR_DESCRIPTIONS = { (BTHomeSensorDeviceClass.TEMPERATURE, Units.TEMP_CELSIUS): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.TEMPERATURE}_{Units.TEMP_CELSIUS}", device_class=SensorDeviceClass.TEMPERATURE, - native_unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), (BTHomeSensorDeviceClass.HUMIDITY, Units.PERCENTAGE): SensorEntityDescription( @@ -61,7 +65,7 @@ SENSOR_DESCRIPTIONS = { (BTHomeSensorDeviceClass.PRESSURE, Units.PRESSURE_MBAR): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.PRESSURE}_{Units.PRESSURE_MBAR}", device_class=SensorDeviceClass.PRESSURE, - native_unit_of_measurement=PRESSURE_MBAR, + native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, ), (BTHomeSensorDeviceClass.BATTERY, Units.PERCENTAGE): SensorEntityDescription( @@ -86,13 +90,13 @@ SENSOR_DESCRIPTIONS = { ): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.ENERGY}_{Units.ENERGY_KILO_WATT_HOUR}", device_class=SensorDeviceClass.ENERGY, - native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, ), (BTHomeSensorDeviceClass.POWER, Units.POWER_WATT): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.POWER}_{Units.POWER_WATT}", device_class=SensorDeviceClass.POWER, - native_unit_of_measurement=POWER_WATT, + native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), ( @@ -146,14 +150,14 @@ SENSOR_DESCRIPTIONS = { (BTHomeSensorDeviceClass.MASS, Units.MASS_KILOGRAMS): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.MASS}_{Units.MASS_KILOGRAMS}", device_class=SensorDeviceClass.WEIGHT, - native_unit_of_measurement=MASS_KILOGRAMS, + native_unit_of_measurement=UnitOfMass.KILOGRAMS, state_class=SensorStateClass.MEASUREMENT, ), # Used for mass sensor with lb unit (BTHomeSensorDeviceClass.MASS, Units.MASS_POUNDS): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.MASS}_{Units.MASS_POUNDS}", device_class=SensorDeviceClass.WEIGHT, - native_unit_of_measurement=MASS_POUNDS, + native_unit_of_measurement=UnitOfMass.POUNDS, state_class=SensorStateClass.MEASUREMENT, ), # Used for moisture sensor @@ -167,7 +171,7 @@ SENSOR_DESCRIPTIONS = { (BTHomeSensorDeviceClass.DEW_POINT, Units.TEMP_CELSIUS): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.DEW_POINT}_{Units.TEMP_CELSIUS}", device_class=SensorDeviceClass.TEMPERATURE, - native_unit_of_measurement=TEMP_CELSIUS, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, ), # Used for count sensor @@ -176,6 +180,64 @@ SENSOR_DESCRIPTIONS = { device_class=None, state_class=SensorStateClass.MEASUREMENT, ), + # Used for rotation sensor + (BTHomeSensorDeviceClass.ROTATION, Units.DEGREE): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.ROTATION}_{Units.DEGREE}", + device_class=None, + native_unit_of_measurement=DEGREE, + state_class=SensorStateClass.MEASUREMENT, + ), + # Used for distance sensor in mm + ( + BTHomeSensorDeviceClass.DISTANCE, + Units.LENGTH_MILLIMETERS, + ): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.DISTANCE}_{Units.LENGTH_MILLIMETERS}", + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + state_class=SensorStateClass.MEASUREMENT, + ), + # Used for distance sensor in m + (BTHomeSensorDeviceClass.DISTANCE, Units.LENGTH_METERS): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.DISTANCE}_{Units.LENGTH_METERS}", + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.METERS, + state_class=SensorStateClass.MEASUREMENT, + ), + # Used for duration sensor + (BTHomeSensorDeviceClass.DURATION, Units.TIME_SECONDS): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.DURATION}_{Units.TIME_SECONDS}", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=TIME_SECONDS, + state_class=SensorStateClass.MEASUREMENT, + ), + # Used for current sensor + ( + BTHomeSensorDeviceClass.CURRENT, + Units.ELECTRIC_CURRENT_AMPERE, + ): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.CURRENT}_{Units.ELECTRIC_CURRENT_AMPERE}", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + ), + # Used for speed sensor + ( + BTHomeSensorDeviceClass.SPEED, + Units.SPEED_METERS_PER_SECOND, + ): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.SPEED}_{Units.SPEED_METERS_PER_SECOND}", + device_class=SensorDeviceClass.SPEED, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + ), + # Used for UV index sensor + (BTHomeSensorDeviceClass.UV_INDEX, None,): SensorEntityDescription( + key=f"{BTHomeSensorDeviceClass.UV_INDEX}", + device_class=None, + native_unit_of_measurement=None, + state_class=SensorStateClass.MEASUREMENT, + ), } diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 4a0b9529ee7..355340d3ed3 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -36,6 +36,11 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "connectable": False, "service_data_uuid": "0000181e-0000-1000-8000-00805f9b34fb", }, + { + "domain": "bthome", + "connectable": False, + "service_data_uuid": "0000fcd2-0000-1000-8000-00805f9b34fb", + }, { "domain": "fjaraskupan", "connectable": False, diff --git a/requirements_all.txt b/requirements_all.txt index d8dab654842..7fea5716280 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -482,7 +482,7 @@ brunt==1.2.0 bt_proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==1.2.2 +bthome-ble==2.2.1 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 041b8577331..a62c14f197d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -386,7 +386,7 @@ brother==2.0.0 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==1.2.2 +bthome-ble==2.2.1 # homeassistant.components.buienradar buienradar==1.0.5 diff --git a/tests/components/bthome/__init__.py b/tests/components/bthome/__init__.py index 25ccb72edfa..2951413b0e6 100644 --- a/tests/components/bthome/__init__.py +++ b/tests/components/bthome/__init__.py @@ -85,7 +85,7 @@ NOT_BTHOME_SERVICE_INFO = BluetoothServiceInfoBleak( ) -def make_advertisement(address: str, payload: bytes) -> BluetoothServiceInfoBleak: +def make_bthome_v1_adv(address: str, payload: bytes) -> BluetoothServiceInfoBleak: """Make a dummy advertisement.""" return BluetoothServiceInfoBleak( name="Test Device", @@ -104,7 +104,7 @@ def make_advertisement(address: str, payload: bytes) -> BluetoothServiceInfoBlea ) -def make_encrypted_advertisement( +def make_encrypted_bthome_v1_adv( address: str, payload: bytes ) -> BluetoothServiceInfoBleak: """Make a dummy encrypted advertisement.""" @@ -123,3 +123,22 @@ def make_encrypted_advertisement( time=0, connectable=False, ) + + +def make_bthome_v2_adv(address: str, payload: bytes) -> BluetoothServiceInfoBleak: + """Make a dummy advertisement.""" + return BluetoothServiceInfoBleak( + name="Test Device", + address=address, + device=BLEDevice(address, None), + rssi=-56, + manufacturer_data={}, + service_data={ + "0000fcd2-0000-1000-8000-00805f9b34fb": payload, + }, + service_uuids=["0000fcd2-0000-1000-8000-00805f9b34fb"], + source="local", + advertisement=generate_advertisement_data(local_name="Test Device"), + time=0, + connectable=False, + ) diff --git a/tests/components/bthome/test_binary_sensor.py b/tests/components/bthome/test_binary_sensor.py index 64b19b17a81..99c3b310678 100644 --- a/tests/components/bthome/test_binary_sensor.py +++ b/tests/components/bthome/test_binary_sensor.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components.bthome.const import DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF, STATE_ON -from . import make_advertisement +from . import make_bthome_v1_adv, make_bthome_v2_adv from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info @@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) [ ( "A4:C1:38:8D:18:B2", - make_advertisement( + make_bthome_v1_adv( "A4:C1:38:8D:18:B2", b"\x02\x10\x01", ), @@ -35,7 +35,7 @@ _LOGGER = logging.getLogger(__name__) ), ( "A4:C1:38:8D:18:B2", - make_advertisement( + make_bthome_v1_adv( "A4:C1:38:8D:18:B2", b"\x02\x11\x00", ), @@ -50,7 +50,7 @@ _LOGGER = logging.getLogger(__name__) ), ( "A4:C1:38:8D:18:B2", - make_advertisement( + make_bthome_v1_adv( "A4:C1:38:8D:18:B2", b"\x02\x0F\x01", ), @@ -65,14 +65,100 @@ _LOGGER = logging.getLogger(__name__) ), ], ) -async def test_binary_sensors( +async def test_v1_binary_sensors( hass, mac_address, advertisement, bind_key, result, ): - """Test the different binary sensors.""" + """Test the different BTHome v1 binary sensors.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=mac_address, + data={"bindkey": bind_key}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + 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() + + +@pytest.mark.parametrize( + "mac_address, advertisement, bind_key, result", + [ + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\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_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\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_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\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_v2_binary_sensors( + hass, + mac_address, + advertisement, + bind_key, + result, +): + """Test the different BTHome v2 binary sensors.""" entry = MockConfigEntry( domain=DOMAIN, unique_id=mac_address, diff --git a/tests/components/bthome/test_sensor.py b/tests/components/bthome/test_sensor.py index 78b247aa393..989fff1a25f 100644 --- a/tests/components/bthome/test_sensor.py +++ b/tests/components/bthome/test_sensor.py @@ -1,24 +1,29 @@ """Test the BTHome sensors.""" +import logging + import pytest from homeassistant.components.bthome.const import DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT -from . import make_advertisement, make_encrypted_advertisement +from . import make_bthome_v1_adv, make_bthome_v2_adv, make_encrypted_bthome_v1_adv from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info +_LOGGER = logging.getLogger(__name__) + +# Tests for BTHome v1 @pytest.mark.parametrize( "mac_address, advertisement, bind_key, result", [ ( "A4:C1:38:8D:18:B2", - make_advertisement( + make_bthome_v1_adv( "A4:C1:38:8D:18:B2", b"#\x02\xca\t\x03\x03\xbf\x13", ), @@ -42,7 +47,7 @@ from tests.components.bluetooth import inject_bluetooth_service_info ), ( "A4:C1:38:8D:18:B2", - make_advertisement( + make_bthome_v1_adv( "A4:C1:38:8D:18:B2", b"\x02\x00\xa8#\x02]\t\x03\x03\xb7\x18\x02\x01]", ), @@ -73,7 +78,7 @@ from tests.components.bluetooth import inject_bluetooth_service_info ), ( "A4:C1:38:8D:18:B2", - make_advertisement( + make_bthome_v1_adv( "A4:C1:38:8D:18:B2", b"\x02\x00\x0c\x04\x04\x13\x8a\x01", ), @@ -90,7 +95,7 @@ from tests.components.bluetooth import inject_bluetooth_service_info ), ( "AA:BB:CC:DD:EE:FF", - make_advertisement( + make_bthome_v1_adv( "AA:BB:CC:DD:EE:FF", b"\x04\x05\x13\x8a\x14", ), @@ -107,7 +112,7 @@ from tests.components.bluetooth import inject_bluetooth_service_info ), ( "A4:C1:38:8D:18:B2", - make_advertisement( + make_bthome_v1_adv( "A4:C1:38:8D:18:B2", b"\x03\x06\x5e\x1f", ), @@ -124,7 +129,7 @@ from tests.components.bluetooth import inject_bluetooth_service_info ), ( "A4:C1:38:8D:18:B2", - make_advertisement( + make_bthome_v1_adv( "A4:C1:38:8D:18:B2", b"\x03\x07\x3e\x1d", ), @@ -141,7 +146,7 @@ from tests.components.bluetooth import inject_bluetooth_service_info ), ( "A4:C1:38:8D:18:B2", - make_advertisement( + make_bthome_v1_adv( "A4:C1:38:8D:18:B2", b"\x23\x08\xCA\x06", ), @@ -158,7 +163,7 @@ from tests.components.bluetooth import inject_bluetooth_service_info ), ( "A4:C1:38:8D:18:B2", - make_advertisement( + make_bthome_v1_adv( "A4:C1:38:8D:18:B2", b"\x02\x09\x60", ), @@ -174,7 +179,7 @@ from tests.components.bluetooth import inject_bluetooth_service_info ), ( "A4:C1:38:8D:18:B2", - make_advertisement( + make_bthome_v1_adv( "A4:C1:38:8D:18:B2", b"\x04\n\x13\x8a\x14", ), @@ -191,7 +196,7 @@ from tests.components.bluetooth import inject_bluetooth_service_info ), ( "A4:C1:38:8D:18:B2", - make_advertisement( + make_bthome_v1_adv( "A4:C1:38:8D:18:B2", b"\x04\x0b\x02\x1b\x00", ), @@ -208,7 +213,7 @@ from tests.components.bluetooth import inject_bluetooth_service_info ), ( "A4:C1:38:8D:18:B2", - make_advertisement( + make_bthome_v1_adv( "A4:C1:38:8D:18:B2", b"\x03\x0c\x02\x0c", ), @@ -225,7 +230,7 @@ from tests.components.bluetooth import inject_bluetooth_service_info ), ( "A4:C1:38:8D:18:B2", - make_advertisement( + make_bthome_v1_adv( "A4:C1:38:8D:18:B2", b"\x03\r\x12\x0c\x03\x0e\x02\x1c", ), @@ -249,7 +254,7 @@ from tests.components.bluetooth import inject_bluetooth_service_info ), ( "A4:C1:38:8D:18:B2", - make_advertisement( + make_bthome_v1_adv( "A4:C1:38:8D:18:B2", b"\x03\x12\xe2\x04", ), @@ -266,7 +271,7 @@ from tests.components.bluetooth import inject_bluetooth_service_info ), ( "A4:C1:38:8D:18:B2", - make_advertisement( + make_bthome_v1_adv( "A4:C1:38:8D:18:B2", b"\x03\x133\x01", ), @@ -283,7 +288,7 @@ from tests.components.bluetooth import inject_bluetooth_service_info ), ( "A4:C1:38:8D:18:B2", - make_advertisement( + make_bthome_v1_adv( "A4:C1:38:8D:18:B2", b"\x03\x14\x02\x0c", ), @@ -300,7 +305,7 @@ from tests.components.bluetooth import inject_bluetooth_service_info ), ( "54:48:E6:8F:80:A5", - make_encrypted_advertisement( + make_encrypted_bthome_v1_adv( "54:48:E6:8F:80:A5", b'\xfb\xa45\xe4\xd3\xc3\x12\xfb\x00\x11"3W\xd9\n\x99', ), @@ -324,14 +329,14 @@ from tests.components.bluetooth import inject_bluetooth_service_info ), ], ) -async def test_sensors( +async def test_v1_sensors( hass, mac_address, advertisement, bind_key, result, ): - """Test the different measurement sensors.""" + """Test the different BTHome V1 sensors.""" entry = MockConfigEntry( domain=DOMAIN, unique_id=mac_address, @@ -357,7 +362,572 @@ async def test_sensors( assert sensor.state == meas["expected_state"] assert sensor_attr[ATTR_FRIENDLY_NAME] == meas["friendly_name"] if ATTR_UNIT_OF_MEASUREMENT in sensor_attr: - # Count sensor does not have a unit of measurement + # Some sensors don't have a unit of measurement + assert sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == meas["unit_of_measurement"] + assert sensor_attr[ATTR_STATE_CLASS] == meas["state_class"] + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +# Tests for BTHome V2 +@pytest.mark.parametrize( + "mac_address, advertisement, bind_key, result", + [ + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x02\xca\x09\x03\xbf\x13", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_temperature", + "friendly_name": "Test Device 18B2 Temperature", + "unit_of_measurement": "°C", + "state_class": "measurement", + "expected_state": "25.06", + }, + { + "sensor_entity": "sensor.test_device_18b2_humidity", + "friendly_name": "Test Device 18B2 Humidity", + "unit_of_measurement": "%", + "state_class": "measurement", + "expected_state": "50.55", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x01\x5d\x02\x5d\x09\x03\xb7\x18", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_temperature", + "friendly_name": "Test Device 18B2 Temperature", + "unit_of_measurement": "°C", + "state_class": "measurement", + "expected_state": "23.97", + }, + { + "sensor_entity": "sensor.test_device_18b2_humidity", + "friendly_name": "Test Device 18B2 Humidity", + "unit_of_measurement": "%", + "state_class": "measurement", + "expected_state": "63.27", + }, + { + "sensor_entity": "sensor.test_device_18b2_battery", + "friendly_name": "Test Device 18B2 Battery", + "unit_of_measurement": "%", + "state_class": "measurement", + "expected_state": "93", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x04\x13\x8a\x01", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_pressure", + "friendly_name": "Test Device 18B2 Pressure", + "unit_of_measurement": "mbar", + "state_class": "measurement", + "expected_state": "1008.83", + }, + ], + ), + ( + "AA:BB:CC:DD:EE:FF", + make_bthome_v2_adv( + "AA:BB:CC:DD:EE:FF", + b"\x40\x05\x13\x8a\x14", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_eeff_illuminance", + "friendly_name": "Test Device EEFF Illuminance", + "unit_of_measurement": "lx", + "state_class": "measurement", + "expected_state": "13460.67", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x06\x5E\x1F", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_mass", + "friendly_name": "Test Device 18B2 Mass", + "unit_of_measurement": "kg", + "state_class": "measurement", + "expected_state": "80.3", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x07\x3E\x1d", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_mass", + "friendly_name": "Test Device 18B2 Mass", + "unit_of_measurement": "lb", + "state_class": "measurement", + "expected_state": "74.86", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x08\xCA\x06", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_dew_point", + "friendly_name": "Test Device 18B2 Dew Point", + "unit_of_measurement": "°C", + "state_class": "measurement", + "expected_state": "17.38", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x09\x60", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_count", + "friendly_name": "Test Device 18B2 Count", + "state_class": "measurement", + "expected_state": "96", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x0a\x13\x8a\x14", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_energy", + "friendly_name": "Test Device 18B2 Energy", + "unit_of_measurement": "kWh", + "state_class": "total_increasing", + "expected_state": "1346.067", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x0b\x02\x1b\x00", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_power", + "friendly_name": "Test Device 18B2 Power", + "unit_of_measurement": "W", + "state_class": "measurement", + "expected_state": "69.14", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x0c\x02\x0c", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_voltage", + "friendly_name": "Test Device 18B2 Voltage", + "unit_of_measurement": "V", + "state_class": "measurement", + "expected_state": "3.074", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x0d\x12\x0c\x0e\x02\x1c", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_pm10", + "friendly_name": "Test Device 18B2 Pm10", + "unit_of_measurement": "µg/m³", + "state_class": "measurement", + "expected_state": "7170", + }, + { + "sensor_entity": "sensor.test_device_18b2_pm25", + "friendly_name": "Test Device 18B2 Pm25", + "unit_of_measurement": "µg/m³", + "state_class": "measurement", + "expected_state": "3090", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x12\xe2\x04", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_carbon_dioxide", + "friendly_name": "Test Device 18B2 Carbon Dioxide", + "unit_of_measurement": "ppm", + "state_class": "measurement", + "expected_state": "1250", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x133\x01", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_volatile_organic_compounds", + "friendly_name": "Test Device 18B2 Volatile Organic Compounds", + "unit_of_measurement": "µg/m³", + "state_class": "measurement", + "expected_state": "307", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x14\x02\x0c", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_moisture", + "friendly_name": "Test Device 18B2 Moisture", + "unit_of_measurement": "%", + "state_class": "measurement", + "expected_state": "30.74", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x3F\x02\x0c", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_rotation", + "friendly_name": "Test Device 18B2 Rotation", + "unit_of_measurement": "°", + "state_class": "measurement", + "expected_state": "307.4", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x40\x0C\x00", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_distance", + "friendly_name": "Test Device 18B2 Distance", + "unit_of_measurement": "mm", + "state_class": "measurement", + "expected_state": "12", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x41\x4E\x00", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_distance", + "friendly_name": "Test Device 18B2 Distance", + "unit_of_measurement": "m", + "state_class": "measurement", + "expected_state": "7.8", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x42\x4E\x34\x00", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_duration", + "friendly_name": "Test Device 18B2 Duration", + "unit_of_measurement": "s", + "state_class": "measurement", + "expected_state": "13.39", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x43\x4E\x34", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_current", + "friendly_name": "Test Device 18B2 Current", + "unit_of_measurement": "A", + "state_class": "measurement", + "expected_state": "13.39", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x44\x4E\x34", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_speed", + "friendly_name": "Test Device 18B2 Speed", + "unit_of_measurement": "m/s", + "state_class": "measurement", + "expected_state": "133.9", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x45\x11\x01", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_temperature", + "friendly_name": "Test Device 18B2 Temperature", + "unit_of_measurement": "°C", + "state_class": "measurement", + "expected_state": "27.3", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x46\x32", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_uv_index", + "friendly_name": "Test Device 18B2 Uv Index", + "state_class": "measurement", + "expected_state": "5.0", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x02\xca\x09\x02\xcf\x09", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_temperature_1", + "friendly_name": "Test Device 18B2 Temperature 1", + "unit_of_measurement": "°C", + "state_class": "measurement", + "expected_state": "25.06", + }, + { + "sensor_entity": "sensor.test_device_18b2_temperature_2", + "friendly_name": "Test Device 18B2 Temperature 2", + "unit_of_measurement": "°C", + "state_class": "measurement", + "expected_state": "25.11", + }, + ], + ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x02\xca\x09\x02\xcf\x09\x02\xcf\x08\x03\xb7\x18\x03\xb7\x17\x01\x5d", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_temperature_1", + "friendly_name": "Test Device 18B2 Temperature 1", + "unit_of_measurement": "°C", + "state_class": "measurement", + "expected_state": "25.06", + }, + { + "sensor_entity": "sensor.test_device_18b2_temperature_2", + "friendly_name": "Test Device 18B2 Temperature 2", + "unit_of_measurement": "°C", + "state_class": "measurement", + "expected_state": "25.11", + }, + { + "sensor_entity": "sensor.test_device_18b2_temperature_3", + "friendly_name": "Test Device 18B2 Temperature 3", + "unit_of_measurement": "°C", + "state_class": "measurement", + "expected_state": "22.55", + }, + { + "sensor_entity": "sensor.test_device_18b2_humidity_1", + "friendly_name": "Test Device 18B2 Humidity 1", + "unit_of_measurement": "%", + "state_class": "measurement", + "expected_state": "63.27", + }, + { + "sensor_entity": "sensor.test_device_18b2_humidity_2", + "friendly_name": "Test Device 18B2 Humidity 2", + "unit_of_measurement": "%", + "state_class": "measurement", + "expected_state": "60.71", + }, + { + "sensor_entity": "sensor.test_device_18b2_battery", + "friendly_name": "Test Device 18B2 Battery", + "unit_of_measurement": "%", + "state_class": "measurement", + "expected_state": "93", + }, + ], + ), + ( + "54:48:E6:8F:80:A5", + make_bthome_v2_adv( + "54:48:E6:8F:80:A5", + b"\x41\xa4\x72\x66\xc9\x5f\x73\x00\x11\x22\x33\xb7\xce\xd8\xe5", + ), + "231d39c1d7cc1ab1aee224cd096db932", + [ + { + "sensor_entity": "sensor.test_device_80a5_temperature", + "friendly_name": "Test Device 80A5 Temperature", + "unit_of_measurement": "°C", + "state_class": "measurement", + "expected_state": "25.06", + }, + { + "sensor_entity": "sensor.test_device_80a5_humidity", + "friendly_name": "Test Device 80A5 Humidity", + "unit_of_measurement": "%", + "state_class": "measurement", + "expected_state": "50.55", + }, + ], + ), + ], +) +async def test_v2_sensors( + hass, + mac_address, + advertisement, + bind_key, + result, +): + """Test the different BTHome V2 sensors.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=mac_address, + data={"bindkey": bind_key}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + advertisement, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == len(result) + + for meas in result: + _LOGGER.error(meas) + sensor = hass.states.get(meas["sensor_entity"]) + _LOGGER.error(hass.states) + sensor_attr = sensor.attributes + assert sensor.state == meas["expected_state"] + assert sensor_attr[ATTR_FRIENDLY_NAME] == meas["friendly_name"] + if ATTR_UNIT_OF_MEASUREMENT in sensor_attr: + # Some sensors don't have a unit of measurement assert sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == meas["unit_of_measurement"] assert sensor_attr[ATTR_STATE_CLASS] == meas["state_class"] assert await hass.config_entries.async_unload(entry.entry_id)