mirror of
https://github.com/home-assistant/core.git
synced 2025-04-25 17:57:55 +00:00
Avoid dispatching same state to passive bluetooth entities (#102430)
This commit is contained in:
parent
311e539c0e
commit
b3bd34a024
@ -21,6 +21,7 @@ from homeassistant.helpers.entity import Entity, EntityDescription
|
|||||||
from homeassistant.helpers.entity_platform import async_get_current_platform
|
from homeassistant.helpers.entity_platform import async_get_current_platform
|
||||||
from homeassistant.helpers.event import async_track_time_interval
|
from homeassistant.helpers.event import async_track_time_interval
|
||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
|
from homeassistant.helpers.typing import UNDEFINED
|
||||||
from homeassistant.util.enum import try_parse_enum
|
from homeassistant.util.enum import try_parse_enum
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
@ -134,12 +135,33 @@ class PassiveBluetoothDataUpdate(Generic[_T]):
|
|||||||
default_factory=dict
|
default_factory=dict
|
||||||
)
|
)
|
||||||
|
|
||||||
def update(self, new_data: PassiveBluetoothDataUpdate[_T]) -> None:
|
def update(
|
||||||
"""Update the data."""
|
self, new_data: PassiveBluetoothDataUpdate[_T]
|
||||||
self.devices.update(new_data.devices)
|
) -> set[PassiveBluetoothEntityKey] | None:
|
||||||
self.entity_descriptions.update(new_data.entity_descriptions)
|
"""Update the data and returned changed PassiveBluetoothEntityKey or None on device change.
|
||||||
self.entity_data.update(new_data.entity_data)
|
|
||||||
self.entity_names.update(new_data.entity_names)
|
The changed PassiveBluetoothEntityKey can be used to filter
|
||||||
|
which listeners are called.
|
||||||
|
"""
|
||||||
|
device_change = False
|
||||||
|
changed_entity_keys: set[PassiveBluetoothEntityKey] = set()
|
||||||
|
for key, device_info in new_data.devices.items():
|
||||||
|
if device_change or self.devices.get(key, UNDEFINED) != device_info:
|
||||||
|
device_change = True
|
||||||
|
self.devices[key] = device_info
|
||||||
|
for incoming, current in (
|
||||||
|
(new_data.entity_descriptions, self.entity_descriptions),
|
||||||
|
(new_data.entity_names, self.entity_names),
|
||||||
|
(new_data.entity_data, self.entity_data),
|
||||||
|
):
|
||||||
|
# mypy can't seem to work this out
|
||||||
|
for key, data in incoming.items(): # type: ignore[attr-defined]
|
||||||
|
if current.get(key, UNDEFINED) != data: # type: ignore[attr-defined]
|
||||||
|
changed_entity_keys.add(key) # type: ignore[arg-type]
|
||||||
|
current[key] = data # type: ignore[index]
|
||||||
|
# If the device changed we don't need to return the changed
|
||||||
|
# entity keys as all entities will be updated
|
||||||
|
return None if device_change else changed_entity_keys
|
||||||
|
|
||||||
def async_get_restore_data(self) -> RestoredPassiveBluetoothDataUpdate:
|
def async_get_restore_data(self) -> RestoredPassiveBluetoothDataUpdate:
|
||||||
"""Serialize restore data to storage."""
|
"""Serialize restore data to storage."""
|
||||||
@ -520,6 +542,7 @@ class PassiveBluetoothDataProcessor(Generic[_T]):
|
|||||||
self,
|
self,
|
||||||
data: PassiveBluetoothDataUpdate[_T] | None,
|
data: PassiveBluetoothDataUpdate[_T] | None,
|
||||||
was_available: bool | None = None,
|
was_available: bool | None = None,
|
||||||
|
changed_entity_keys: set[PassiveBluetoothEntityKey] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update all registered listeners."""
|
"""Update all registered listeners."""
|
||||||
if was_available is None:
|
if was_available is None:
|
||||||
@ -542,6 +565,12 @@ class PassiveBluetoothDataProcessor(Generic[_T]):
|
|||||||
# if the key is in the data
|
# if the key is in the data
|
||||||
entity_key_listeners = self._entity_key_listeners
|
entity_key_listeners = self._entity_key_listeners
|
||||||
for entity_key in data.entity_data:
|
for entity_key in data.entity_data:
|
||||||
|
if (
|
||||||
|
was_available
|
||||||
|
and changed_entity_keys is not None
|
||||||
|
and entity_key not in changed_entity_keys
|
||||||
|
):
|
||||||
|
continue
|
||||||
if maybe_listener := entity_key_listeners.get(entity_key):
|
if maybe_listener := entity_key_listeners.get(entity_key):
|
||||||
for update_callback in maybe_listener:
|
for update_callback in maybe_listener:
|
||||||
update_callback(data)
|
update_callback(data)
|
||||||
@ -573,8 +602,8 @@ class PassiveBluetoothDataProcessor(Generic[_T]):
|
|||||||
"Processing %s data recovered", self.coordinator.name
|
"Processing %s data recovered", self.coordinator.name
|
||||||
)
|
)
|
||||||
|
|
||||||
self.data.update(new_data)
|
changed_entity_keys = self.data.update(new_data)
|
||||||
self.async_update_listeners(new_data, was_available)
|
self.async_update_listeners(new_data, was_available, changed_entity_keys)
|
||||||
|
|
||||||
|
|
||||||
class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProcessorT]):
|
class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProcessorT]):
|
||||||
|
@ -112,6 +112,65 @@ GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE_WITH_TEMP_CHANGE = PassiveBluetoothDataUpdate(
|
||||||
|
devices={
|
||||||
|
None: DeviceInfo(
|
||||||
|
name="Test Device", model="Test Model", manufacturer="Test Manufacturer"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
entity_data={
|
||||||
|
PassiveBluetoothEntityKey("temperature", None): 15.5,
|
||||||
|
PassiveBluetoothEntityKey("pressure", None): 1234,
|
||||||
|
},
|
||||||
|
entity_names={
|
||||||
|
PassiveBluetoothEntityKey("temperature", None): "Temperature",
|
||||||
|
PassiveBluetoothEntityKey("pressure", None): "Pressure",
|
||||||
|
},
|
||||||
|
entity_descriptions={
|
||||||
|
PassiveBluetoothEntityKey("temperature", None): SensorEntityDescription(
|
||||||
|
key="temperature",
|
||||||
|
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||||
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
|
),
|
||||||
|
PassiveBluetoothEntityKey("pressure", None): SensorEntityDescription(
|
||||||
|
key="pressure",
|
||||||
|
native_unit_of_measurement="hPa",
|
||||||
|
device_class=SensorDeviceClass.PRESSURE,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE_WITH_DEVICE_NAME_AND_TEMP_CHANGE = (
|
||||||
|
PassiveBluetoothDataUpdate(
|
||||||
|
devices={
|
||||||
|
None: DeviceInfo(
|
||||||
|
name="Changed", model="Test Model", manufacturer="Test Manufacturer"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
entity_data={
|
||||||
|
PassiveBluetoothEntityKey("temperature", None): 15.5,
|
||||||
|
PassiveBluetoothEntityKey("pressure", None): 1234,
|
||||||
|
},
|
||||||
|
entity_names={
|
||||||
|
PassiveBluetoothEntityKey("temperature", None): "Temperature",
|
||||||
|
PassiveBluetoothEntityKey("pressure", None): "Pressure",
|
||||||
|
},
|
||||||
|
entity_descriptions={
|
||||||
|
PassiveBluetoothEntityKey("temperature", None): SensorEntityDescription(
|
||||||
|
key="temperature",
|
||||||
|
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||||
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
|
),
|
||||||
|
PassiveBluetoothEntityKey("pressure", None): SensorEntityDescription(
|
||||||
|
key="pressure",
|
||||||
|
native_unit_of_measurement="hPa",
|
||||||
|
device_class=SensorDeviceClass.PRESSURE,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_basic_usage(
|
async def test_basic_usage(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@ -189,9 +248,9 @@ async def test_basic_usage(
|
|||||||
|
|
||||||
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2)
|
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2)
|
||||||
|
|
||||||
# Each listener should receive the same data
|
# Only the all listener should receive the new data
|
||||||
# since both match
|
# since temperature is not in the new data
|
||||||
assert len(entity_key_events) == 2
|
assert len(entity_key_events) == 1
|
||||||
assert len(all_events) == 2
|
assert len(all_events) == 2
|
||||||
|
|
||||||
# On the second, the entities should already be created
|
# On the second, the entities should already be created
|
||||||
@ -206,8 +265,130 @@ async def test_basic_usage(
|
|||||||
|
|
||||||
# Each listener should not trigger any more now
|
# Each listener should not trigger any more now
|
||||||
# that they were cancelled
|
# that they were cancelled
|
||||||
|
assert len(entity_key_events) == 1
|
||||||
|
assert len(all_events) == 2
|
||||||
|
assert len(mock_entity.mock_calls) == 2
|
||||||
|
assert coordinator.available is True
|
||||||
|
|
||||||
|
unregister_processor()
|
||||||
|
cancel_coordinator()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_entity_key_is_dispatched_on_entity_key_change(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_bleak_scanner_start: MagicMock,
|
||||||
|
mock_bluetooth_adapters: None,
|
||||||
|
) -> None:
|
||||||
|
"""Test entity key listeners are only dispatched on change."""
|
||||||
|
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||||
|
update_count = 0
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _mock_update_method(
|
||||||
|
service_info: BluetoothServiceInfo,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
return {"test": "data"}
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_generate_mock_data(
|
||||||
|
data: dict[str, str],
|
||||||
|
) -> PassiveBluetoothDataUpdate:
|
||||||
|
"""Generate mock data."""
|
||||||
|
assert data == {"test": "data"}
|
||||||
|
nonlocal update_count
|
||||||
|
update_count += 1
|
||||||
|
if update_count > 2:
|
||||||
|
return (
|
||||||
|
GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE_WITH_DEVICE_NAME_AND_TEMP_CHANGE
|
||||||
|
)
|
||||||
|
if update_count > 1:
|
||||||
|
return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE_WITH_TEMP_CHANGE
|
||||||
|
return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE
|
||||||
|
|
||||||
|
coordinator = PassiveBluetoothProcessorCoordinator(
|
||||||
|
hass,
|
||||||
|
_LOGGER,
|
||||||
|
"aa:bb:cc:dd:ee:ff",
|
||||||
|
BluetoothScanningMode.ACTIVE,
|
||||||
|
_mock_update_method,
|
||||||
|
)
|
||||||
|
assert coordinator.available is False # no data yet
|
||||||
|
|
||||||
|
processor = PassiveBluetoothDataProcessor(_async_generate_mock_data)
|
||||||
|
|
||||||
|
unregister_processor = coordinator.async_register_processor(processor)
|
||||||
|
cancel_coordinator = coordinator.async_start()
|
||||||
|
|
||||||
|
entity_key = PassiveBluetoothEntityKey("temperature", None)
|
||||||
|
entity_key_events = []
|
||||||
|
all_events = []
|
||||||
|
mock_entity = MagicMock()
|
||||||
|
mock_add_entities = MagicMock()
|
||||||
|
|
||||||
|
def _async_entity_key_listener(data: PassiveBluetoothDataUpdate | None) -> None:
|
||||||
|
"""Mock entity key listener."""
|
||||||
|
entity_key_events.append(data)
|
||||||
|
|
||||||
|
cancel_async_add_entity_key_listener = processor.async_add_entity_key_listener(
|
||||||
|
_async_entity_key_listener,
|
||||||
|
entity_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None:
|
||||||
|
"""Mock an all listener."""
|
||||||
|
all_events.append(data)
|
||||||
|
|
||||||
|
cancel_listener = processor.async_add_listener(
|
||||||
|
_all_listener,
|
||||||
|
)
|
||||||
|
|
||||||
|
cancel_async_add_entities_listener = processor.async_add_entities_listener(
|
||||||
|
mock_entity,
|
||||||
|
mock_add_entities,
|
||||||
|
)
|
||||||
|
|
||||||
|
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO)
|
||||||
|
|
||||||
|
# Each listener should receive the same data
|
||||||
|
# since both match
|
||||||
|
assert len(entity_key_events) == 1
|
||||||
|
assert len(all_events) == 1
|
||||||
|
|
||||||
|
# There should be 4 calls to create entities
|
||||||
|
assert len(mock_entity.mock_calls) == 2
|
||||||
|
|
||||||
|
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2)
|
||||||
|
|
||||||
|
# Both listeners should receive the new data
|
||||||
|
# since temperature IS in the new data
|
||||||
assert len(entity_key_events) == 2
|
assert len(entity_key_events) == 2
|
||||||
assert len(all_events) == 2
|
assert len(all_events) == 2
|
||||||
|
|
||||||
|
# On the second, the entities should already be created
|
||||||
|
# so the mock should not be called again
|
||||||
|
assert len(mock_entity.mock_calls) == 2
|
||||||
|
|
||||||
|
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO)
|
||||||
|
|
||||||
|
# All listeners should receive the data since
|
||||||
|
# the device name changed
|
||||||
|
assert len(entity_key_events) == 3
|
||||||
|
assert len(all_events) == 3
|
||||||
|
|
||||||
|
# On the second, the entities should already be created
|
||||||
|
# so the mock should not be called again
|
||||||
|
assert len(mock_entity.mock_calls) == 2
|
||||||
|
|
||||||
|
cancel_async_add_entity_key_listener()
|
||||||
|
cancel_listener()
|
||||||
|
cancel_async_add_entities_listener()
|
||||||
|
|
||||||
|
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2)
|
||||||
|
|
||||||
|
# Each listener should not trigger any more now
|
||||||
|
# that they were cancelled
|
||||||
|
assert len(entity_key_events) == 3
|
||||||
|
assert len(all_events) == 3
|
||||||
assert len(mock_entity.mock_calls) == 2
|
assert len(mock_entity.mock_calls) == 2
|
||||||
assert coordinator.available is True
|
assert coordinator.available is True
|
||||||
|
|
||||||
@ -897,9 +1078,9 @@ async def test_integration_with_entity(
|
|||||||
# Forth call with both primary and remote sensor entities does not add them again
|
# Forth call with both primary and remote sensor entities does not add them again
|
||||||
assert len(mock_add_entities.mock_calls) == 2
|
assert len(mock_add_entities.mock_calls) == 2
|
||||||
|
|
||||||
# should not have triggered the entity key listener since there
|
# should not have triggered the entity key listener humidity
|
||||||
# there is an update with the entity key
|
# is not in the update
|
||||||
assert len(entity_key_events) == 3
|
assert len(entity_key_events) == 2
|
||||||
|
|
||||||
entities = [
|
entities = [
|
||||||
*mock_add_entities.mock_calls[0][1][0],
|
*mock_add_entities.mock_calls[0][1][0],
|
||||||
|
Loading…
x
Reference in New Issue
Block a user