mirror of
https://github.com/home-assistant/core.git
synced 2025-07-18 18:57:06 +00:00
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:
parent
8e6ec01bfb
commit
f0b6367444
@ -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={
|
||||
(
|
||||
|
@ -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"],
|
||||
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",
|
||||
device=generate_ble_device(
|
||||
"cc:cc:cc:cc:cc:cc",
|
||||
"cc-cc-cc-cc-cc-cc",
|
||||
),
|
||||
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
|
||||
|
213
tests/components/airthings_ble/test_sensor.py
Normal file
213
tests/components/airthings_ble/test_sensor.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user