From 4587c286bb46d4e0cf1bee7bc9d94c7ecf30c305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Tue, 7 Oct 2025 17:44:30 +0200 Subject: [PATCH] Add new sensors for Airthings Wave Enhance (#153879) --- .../components/airthings_ble/sensor.py | 17 ++++ .../components/airthings_ble/strings.json | 3 + tests/components/airthings_ble/__init__.py | 81 ++++++++++++++++--- tests/components/airthings_ble/test_sensor.py | 68 ++++++++++++++-- 4 files changed, 152 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index ee94052c286..49ca7970ae3 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -16,10 +16,12 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, + LIGHT_LUX, PERCENTAGE, EntityCategory, Platform, UnitOfPressure, + UnitOfSoundPressure, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback @@ -112,6 +114,21 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, ), + "lux": SensorEntityDescription( + key="lux", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + ), + "noise": SensorEntityDescription( + key="noise", + translation_key="ambient_noise", + device_class=SensorDeviceClass.SOUND_PRESSURE, + native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + ), } PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/airthings_ble/strings.json b/homeassistant/components/airthings_ble/strings.json index 6799aa20ba7..2755866cdb6 100644 --- a/homeassistant/components/airthings_ble/strings.json +++ b/homeassistant/components/airthings_ble/strings.json @@ -41,6 +41,9 @@ }, "illuminance": { "name": "[%key:component::sensor::entity_component::illuminance::name%]" + }, + "ambient_noise": { + "name": "Ambient noise" } } } diff --git a/tests/components/airthings_ble/__init__.py b/tests/components/airthings_ble/__init__.py index d2dfd6bbf12..cf91634f71f 100644 --- a/tests/components/airthings_ble/__init__.py +++ b/tests/components/airthings_ble/__init__.py @@ -9,12 +9,17 @@ from airthings_ble import ( AirthingsDevice, AirthingsDeviceType, ) +from bleak.backends.device import BLEDevice from homeassistant.components.airthings_ble.const import DOMAIN from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceRegistry +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + DeviceEntry, + DeviceRegistry, +) from tests.common import MockConfigEntry, MockEntity from tests.components.bluetooth import generate_advertisement_data, generate_ble_device @@ -28,7 +33,15 @@ def patch_async_setup_entry(return_value=True): ) -def patch_async_ble_device_from_address(return_value: BluetoothServiceInfoBleak | None): +def patch_async_discovered_service_info(return_value: list[BluetoothServiceInfoBleak]): + """Patch async_discovered_service_info to return given list.""" + return patch( + "homeassistant.components.bluetooth.async_discovered_service_info", + return_value=return_value, + ) + + +def patch_async_ble_device_from_address(return_value: BLEDevice | None): """Patch async ble device from address to return a given value.""" return patch( "homeassistant.components.bluetooth.async_ble_device_from_address", @@ -101,6 +114,27 @@ WAVE_SERVICE_INFO = BluetoothServiceInfoBleak( tx_power=0, ) +WAVE_ENHANCE_SERVICE_INFO = BluetoothServiceInfoBleak( + name="cc-cc-cc-cc-cc-cc", + address="cc:cc:cc:cc:cc:cc", + device=generate_ble_device( + address="cc:cc:cc:cc:cc:cc", + name="Airthings Wave Enhance", + ), + rssi=-61, + manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"}, + service_data={}, + service_uuids=[], + source="local", + advertisement=generate_advertisement_data( + manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"}, + service_uuids=[], + ), + connectable=True, + time=0, + tx_power=0, +) + VIEW_PLUS_SERVICE_INFO = BluetoothServiceInfoBleak( name="cc-cc-cc-cc-cc-cc", address="cc:cc:cc:cc:cc:cc", @@ -211,6 +245,26 @@ WAVE_DEVICE_INFO = AirthingsDevice( address="cc:cc:cc:cc:cc:cc", ) +WAVE_ENHANCE_DEVICE_INFO = AirthingsDevice( + manufacturer="Airthings AS", + hw_version="REV X", + sw_version="T-SUB-2.6.2-master+0", + model=AirthingsDeviceType.WAVE_ENHANCE_EU, + name="Airthings Wave Enhance", + identifier="123456", + sensors={ + "lux": 25, + "battery": 85, + "humidity": 60.0, + "temperature": 21.0, + "co2": 500.0, + "voc": 155.0, + "pressure": 1020, + "noise": 40, + }, + address="cc:cc:cc:cc:cc:cc", +) + TEMPERATURE_V1 = MockEntity( unique_id="Airthings Wave Plus 123456_temperature", name="Airthings Wave Plus 123456 Temperature", @@ -247,23 +301,32 @@ VOC_V3 = MockEntity( ) -def create_entry(hass: HomeAssistant) -> MockConfigEntry: +def create_entry( + hass: HomeAssistant, + service_info: BluetoothServiceInfoBleak, + device_info: AirthingsDevice, +) -> MockConfigEntry: """Create a config entry.""" entry = MockConfigEntry( domain=DOMAIN, - unique_id=WAVE_SERVICE_INFO.address, - title="Airthings Wave Plus (123456)", + unique_id=service_info.address, + title=f"{device_info.name} ({device_info.identifier})", ) entry.add_to_hass(hass) return entry -def create_device(entry: ConfigEntry, device_registry: DeviceRegistry): +def create_device( + entry: ConfigEntry, + device_registry: DeviceRegistry, + service_info: BluetoothServiceInfoBleak, + device_info: AirthingsDevice, +) -> DeviceEntry: """Create a device for the given entry.""" return device_registry.async_get_or_create( config_entry_id=entry.entry_id, - connections={(CONNECTION_BLUETOOTH, WAVE_SERVICE_INFO.address)}, + connections={(CONNECTION_BLUETOOTH, service_info.address)}, manufacturer="Airthings AS", - name="Airthings Wave Plus (123456)", - model="Wave Plus", + name=f"{device_info.name} ({device_info.identifier})", + model=device_info.model.product_name, ) diff --git a/tests/components/airthings_ble/test_sensor.py b/tests/components/airthings_ble/test_sensor.py index a8acdf7ec7b..988dc313dab 100644 --- a/tests/components/airthings_ble/test_sensor.py +++ b/tests/components/airthings_ble/test_sensor.py @@ -2,6 +2,8 @@ import logging +import pytest + from homeassistant.components.airthings_ble.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -16,10 +18,15 @@ from . import ( VOC_V2, VOC_V3, WAVE_DEVICE_INFO, + WAVE_ENHANCE_DEVICE_INFO, + WAVE_ENHANCE_SERVICE_INFO, WAVE_SERVICE_INFO, create_device, create_entry, + patch_airthings_ble, patch_airthings_device_update, + patch_async_ble_device_from_address, + patch_async_discovered_service_info, ) from tests.components.bluetooth import inject_bluetooth_service_info @@ -33,8 +40,8 @@ async def test_migration_from_v1_to_v3_unique_id( device_registry: dr.DeviceRegistry, ) -> None: """Verify that we can migrate from v1 (pre 2023.9.0) to the latest unique id format.""" - entry = create_entry(hass) - device = create_device(entry, device_registry) + entry = create_entry(hass, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO) + device = create_device(entry, device_registry, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO) assert entry is not None assert device is not None @@ -74,8 +81,8 @@ async def test_migration_from_v2_to_v3_unique_id( device_registry: dr.DeviceRegistry, ) -> None: """Verify that we can migrate from v2 (introduced in 2023.9.0) to the latest unique id format.""" - entry = create_entry(hass) - device = create_device(entry, device_registry) + entry = create_entry(hass, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO) + device = create_device(entry, device_registry, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO) assert entry is not None assert device is not None @@ -115,8 +122,8 @@ async def test_migration_from_v1_and_v2_to_v3_unique_id( device_registry: dr.DeviceRegistry, ) -> None: """Test if migration works when we have both v1 (pre 2023.9.0) and v2 (introduced in 2023.9.0) unique ids.""" - entry = create_entry(hass) - device = create_device(entry, device_registry) + entry = create_entry(hass, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO) + device = create_device(entry, device_registry, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO) assert entry is not None assert device is not None @@ -165,8 +172,8 @@ async def test_migration_with_all_unique_ids( device_registry: dr.DeviceRegistry, ) -> None: """Test if migration works when we have all unique ids.""" - entry = create_entry(hass) - device = create_device(entry, device_registry) + entry = create_entry(hass, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO) + device = create_device(entry, device_registry, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO) assert entry is not None assert device is not None @@ -215,3 +222,48 @@ async def test_migration_with_all_unique_ids( assert entity_registry.async_get(v1.entity_id).unique_id == VOC_V1.unique_id assert entity_registry.async_get(v2.entity_id).unique_id == VOC_V2.unique_id assert entity_registry.async_get(v3.entity_id).unique_id == VOC_V3.unique_id + + +@pytest.mark.parametrize( + ("unique_suffix", "expected_sensor_name"), + [ + ("lux", "Illuminance"), + ("noise", "Ambient noise"), + ], +) +async def test_translation_keys( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + unique_suffix: str, + expected_sensor_name: str, +) -> None: + """Test that translated sensor names are correct.""" + entry = create_entry(hass, WAVE_ENHANCE_SERVICE_INFO, WAVE_DEVICE_INFO) + device = create_device( + entry, device_registry, WAVE_ENHANCE_SERVICE_INFO, WAVE_ENHANCE_DEVICE_INFO + ) + + with ( + patch_async_ble_device_from_address(WAVE_ENHANCE_SERVICE_INFO.device), + patch_async_discovered_service_info([WAVE_ENHANCE_SERVICE_INFO]), + patch_airthings_ble(WAVE_ENHANCE_DEVICE_INFO), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert device is not None + assert device.name == "Airthings Wave Enhance (123456)" + + unique_id = f"{WAVE_ENHANCE_DEVICE_INFO.address}_{unique_suffix}" + entity_id = entity_registry.async_get_entity_id(Platform.SENSOR, DOMAIN, unique_id) + assert entity_id is not None + + state = hass.states.get(entity_id) + assert state is not None + + expected_value = WAVE_ENHANCE_DEVICE_INFO.sensors[unique_suffix] + assert state.state == str(expected_value) + + expected_name = f"Airthings Wave Enhance (123456) {expected_sensor_name}" + assert state.attributes.get("friendly_name") == expected_name