Airthings BLE unique id migration (#99832)

* Fix sensor unique id

* Add sensor identifiers

* Migrate entities to new unique id

* Fix linting issues

* Fix crash when migrating entity fails

* Change how entities are migrated

* Remve debug logging

* Remove unneeded async

* Remove migration code from init file

* Add migration code to sensor.py

* Adjust for loops to improve speed

* Bugfixes, improve documentation

* Remove old comment

* Remove unused function parameter

* Address PR feedback

* Add tests

* Improve tests and test data

* Refactor test

* Update logger level

Co-authored-by: J. Nick Koston <nick@koston.org>

* Adjust PR comments

* Address more PR comments

* Address PR comments and adjust tests

* Fix PR comment

---------

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Ståle Storø Hauknes 2023-09-12 15:59:54 +02:00 committed by Paulus Schoutsen
parent 8e6ec01bfb
commit f0b6367444
3 changed files with 365 additions and 11 deletions

View File

@ -5,25 +5,35 @@ import logging
from airthings_ble import AirthingsDevice
from homeassistant import config_entries
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
LIGHT_LUX,
PERCENTAGE,
EntityCategory,
Platform,
UnitOfPressure,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import (
CONNECTION_BLUETOOTH,
DeviceInfo,
async_get as device_async_get,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity_registry import (
RegistryEntry,
async_entries_for_device,
async_get as entity_async_get,
)
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
@ -107,9 +117,43 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
}
@callback
def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None:
"""Migrate entities to new unique ids (with BLE Address)."""
ent_reg = entity_async_get(hass)
unique_id_trailer = f"_{sensor_name}"
new_unique_id = f"{address}{unique_id_trailer}"
if ent_reg.async_get_entity_id(DOMAIN, Platform.SENSOR, new_unique_id):
# New unique id already exists
return
dev_reg = device_async_get(hass)
if not (
device := dev_reg.async_get_device(
connections={(CONNECTION_BLUETOOTH, address)}
)
):
return
entities = async_entries_for_device(
ent_reg,
device_id=device.id,
include_disabled_entities=True,
)
matching_reg_entry: RegistryEntry | None = None
for entry in entities:
if entry.unique_id.endswith(unique_id_trailer) and (
not matching_reg_entry or "(" not in entry.unique_id
):
matching_reg_entry = entry
if not matching_reg_entry:
return
entity_id = matching_reg_entry.entity_id
ent_reg.async_update_entity(entity_id=entity_id, new_unique_id=new_unique_id)
_LOGGER.debug("Migrated entity '%s' to unique id '%s'", entity_id, new_unique_id)
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Airthings BLE sensors."""
@ -137,6 +181,7 @@ async def async_setup_entry(
sensor_value,
)
continue
async_migrate(hass, coordinator.data.address, sensor_type)
entities.append(
AirthingsSensor(coordinator, coordinator.data, sensors_mapping[sensor_type])
)
@ -165,7 +210,7 @@ class AirthingsSensor(
if identifier := airthings_device.identifier:
name += f" ({identifier})"
self._attr_unique_id = f"{name}_{entity_description.key}"
self._attr_unique_id = f"{airthings_device.address}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
connections={
(

View File

@ -5,8 +5,11 @@ from unittest.mock import patch
from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice
from homeassistant.components.airthings_ble.const import DOMAIN
from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH
from tests.common import MockConfigEntry, MockEntity
from tests.components.bluetooth import generate_advertisement_data, generate_ble_device
@ -36,18 +39,52 @@ def patch_airthings_ble(return_value=AirthingsDevice, side_effect=None):
)
def patch_airthings_device_update():
"""Patch airthings-ble device."""
return patch(
"homeassistant.components.airthings_ble.AirthingsBluetoothDeviceData.update_device",
return_value=WAVE_DEVICE_INFO,
)
WAVE_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+",
),
rssi=-61,
manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"},
service_data={},
service_uuids=["b42e1c08-ade7-11e4-89d3-123b93f75cba"],
source="local",
device=generate_ble_device(
"cc:cc:cc:cc:cc:cc",
"cc-cc-cc-cc-cc-cc",
service_data={
# Sensor data
"b42e2a68-ade7-11e4-89d3-123b93f75cba": bytearray(
b"\x01\x02\x03\x04\x00\x05\x00\x06\x00\x07\x00\x08\x00\x09\x00\x0A"
),
# Manufacturer
"00002a29-0000-1000-8000-00805f9b34fb": bytearray(b"Airthings AS"),
# Model
"00002a24-0000-1000-8000-00805f9b34fb": bytearray(b"2930"),
# Identifier
"00002a25-0000-1000-8000-00805f9b34fb": bytearray(b"123456"),
# SW Version
"00002a26-0000-1000-8000-00805f9b34fb": bytearray(b"G-BLE-1.5.3-master+0"),
# HW Version
"00002a27-0000-1000-8000-00805f9b34fb": bytearray(b"REV A"),
# Command
"b42e2d06-ade7-11e4-89d3-123b93f75cba": bytearray(b"\x00"),
},
service_uuids=[
"b42e1c08-ade7-11e4-89d3-123b93f75cba",
"b42e2a68-ade7-11e4-89d3-123b93f75cba",
"00002a29-0000-1000-8000-00805f9b34fb",
"00002a24-0000-1000-8000-00805f9b34fb",
"00002a25-0000-1000-8000-00805f9b34fb",
"00002a26-0000-1000-8000-00805f9b34fb",
"00002a27-0000-1000-8000-00805f9b34fb",
"b42e2d06-ade7-11e4-89d3-123b93f75cba",
],
source="local",
advertisement=generate_advertisement_data(
manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"},
service_uuids=["b42e1c08-ade7-11e4-89d3-123b93f75cba"],
@ -99,3 +136,62 @@ WAVE_DEVICE_INFO = AirthingsDevice(
},
address="cc:cc:cc:cc:cc:cc",
)
TEMPERATURE_V1 = MockEntity(
unique_id="Airthings Wave Plus 123456_temperature",
name="Airthings Wave Plus 123456 Temperature",
)
HUMIDITY_V2 = MockEntity(
unique_id="Airthings Wave Plus (123456)_humidity",
name="Airthings Wave Plus (123456) Humidity",
)
CO2_V1 = MockEntity(
unique_id="Airthings Wave Plus 123456_co2",
name="Airthings Wave Plus 123456 CO2",
)
CO2_V2 = MockEntity(
unique_id="Airthings Wave Plus (123456)_co2",
name="Airthings Wave Plus (123456) CO2",
)
VOC_V1 = MockEntity(
unique_id="Airthings Wave Plus 123456_voc",
name="Airthings Wave Plus 123456 CO2",
)
VOC_V2 = MockEntity(
unique_id="Airthings Wave Plus (123456)_voc",
name="Airthings Wave Plus (123456) VOC",
)
VOC_V3 = MockEntity(
unique_id="cc:cc:cc:cc:cc:cc_voc",
name="Airthings Wave Plus (123456) VOC",
)
def create_entry(hass):
"""Create a config entry."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=WAVE_SERVICE_INFO.address,
title="Airthings Wave Plus (123456)",
)
entry.add_to_hass(hass)
return entry
def create_device(hass, entry):
"""Create a device for the given entry."""
device_registry = hass.helpers.device_registry.async_get(hass)
device = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(CONNECTION_BLUETOOTH, WAVE_SERVICE_INFO.address)},
manufacturer="Airthings AS",
name="Airthings Wave Plus (123456)",
model="Wave Plus",
)
return device

View File

@ -0,0 +1,213 @@
"""Test the Airthings Wave sensor."""
import logging
from homeassistant.components.airthings_ble.const import DOMAIN
from homeassistant.core import HomeAssistant
from tests.components.airthings_ble import (
CO2_V1,
CO2_V2,
HUMIDITY_V2,
TEMPERATURE_V1,
VOC_V1,
VOC_V2,
VOC_V3,
WAVE_DEVICE_INFO,
WAVE_SERVICE_INFO,
create_device,
create_entry,
patch_airthings_device_update,
)
from tests.components.bluetooth import inject_bluetooth_service_info
_LOGGER = logging.getLogger(__name__)
async def test_migration_from_v1_to_v3_unique_id(hass: HomeAssistant):
"""Verify that we can migrate from v1 (pre 2023.9.0) to the latest unique id format."""
entry = create_entry(hass)
device = create_device(hass, entry)
assert entry is not None
assert device is not None
entity_registry = hass.helpers.entity_registry.async_get(hass)
sensor = entity_registry.async_get_or_create(
domain=DOMAIN,
platform="sensor",
unique_id=TEMPERATURE_V1.unique_id,
config_entry=entry,
device_id=device.id,
)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 0
inject_bluetooth_service_info(
hass,
WAVE_SERVICE_INFO,
)
await hass.async_block_till_done()
with patch_airthings_device_update():
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all()) > 0
assert (
entity_registry.async_get(sensor.entity_id).unique_id
== WAVE_DEVICE_INFO.address + "_temperature"
)
async def test_migration_from_v2_to_v3_unique_id(hass: HomeAssistant):
"""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(hass, entry)
assert entry is not None
assert device is not None
entity_registry = hass.helpers.entity_registry.async_get(hass)
await hass.async_block_till_done()
sensor = entity_registry.async_get_or_create(
domain=DOMAIN,
platform="sensor",
unique_id=HUMIDITY_V2.unique_id,
config_entry=entry,
device_id=device.id,
)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 0
inject_bluetooth_service_info(
hass,
WAVE_SERVICE_INFO,
)
await hass.async_block_till_done()
with patch_airthings_device_update():
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all()) > 0
assert (
entity_registry.async_get(sensor.entity_id).unique_id
== WAVE_DEVICE_INFO.address + "_humidity"
)
async def test_migration_from_v1_and_v2_to_v3_unique_id(hass: HomeAssistant):
"""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(hass, entry)
assert entry is not None
assert device is not None
entity_registry = hass.helpers.entity_registry.async_get(hass)
await hass.async_block_till_done()
v2 = entity_registry.async_get_or_create(
domain=DOMAIN,
platform="sensor",
unique_id=CO2_V2.unique_id,
config_entry=entry,
device_id=device.id,
)
v1 = entity_registry.async_get_or_create(
domain=DOMAIN,
platform="sensor",
unique_id=CO2_V1.unique_id,
config_entry=entry,
device_id=device.id,
)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 0
inject_bluetooth_service_info(
hass,
WAVE_SERVICE_INFO,
)
await hass.async_block_till_done()
with patch_airthings_device_update():
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all()) > 0
assert (
entity_registry.async_get(v1.entity_id).unique_id
== WAVE_DEVICE_INFO.address + "_co2"
)
assert entity_registry.async_get(v2.entity_id).unique_id == v2.unique_id
async def test_migration_with_all_unique_ids(hass: HomeAssistant):
"""Test if migration works when we have all unique ids."""
entry = create_entry(hass)
device = create_device(hass, entry)
assert entry is not None
assert device is not None
entity_registry = hass.helpers.entity_registry.async_get(hass)
await hass.async_block_till_done()
v1 = entity_registry.async_get_or_create(
domain=DOMAIN,
platform="sensor",
unique_id=VOC_V1.unique_id,
config_entry=entry,
device_id=device.id,
)
v2 = entity_registry.async_get_or_create(
domain=DOMAIN,
platform="sensor",
unique_id=VOC_V2.unique_id,
config_entry=entry,
device_id=device.id,
)
v3 = entity_registry.async_get_or_create(
domain=DOMAIN,
platform="sensor",
unique_id=VOC_V3.unique_id,
config_entry=entry,
device_id=device.id,
)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 0
inject_bluetooth_service_info(
hass,
WAVE_SERVICE_INFO,
)
await hass.async_block_till_done()
with patch_airthings_device_update():
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all()) > 0
assert entity_registry.async_get(v1.entity_id).unique_id == v1.unique_id
assert entity_registry.async_get(v2.entity_id).unique_id == v2.unique_id
assert entity_registry.async_get(v3.entity_id).unique_id == v3.unique_id