mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 20:57:21 +00:00
Fix Bluetooth passive update processor dispatching updates to unchanged entities (#99527)
* Fix passive update processor dispatching updates to unchanged entities * adjust tests * coverage * fix * Update homeassistant/components/bluetooth/update_coordinator.py
This commit is contained in:
parent
0b383067ef
commit
63273a307a
@ -85,6 +85,7 @@ class PassiveBluetoothDataUpdateCoordinator(
|
|||||||
change: BluetoothChange,
|
change: BluetoothChange,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle a Bluetooth event."""
|
"""Handle a Bluetooth event."""
|
||||||
|
self._available = True
|
||||||
self.async_update_listeners()
|
self.async_update_listeners()
|
||||||
|
|
||||||
|
|
||||||
|
@ -341,6 +341,8 @@ class PassiveBluetoothProcessorCoordinator(
|
|||||||
change: BluetoothChange,
|
change: BluetoothChange,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle a Bluetooth event."""
|
"""Handle a Bluetooth event."""
|
||||||
|
was_available = self._available
|
||||||
|
self._available = True
|
||||||
if self.hass.is_stopping:
|
if self.hass.is_stopping:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -358,7 +360,7 @@ class PassiveBluetoothProcessorCoordinator(
|
|||||||
self.logger.info("Coordinator %s recovered", self.name)
|
self.logger.info("Coordinator %s recovered", self.name)
|
||||||
|
|
||||||
for processor in self._processors:
|
for processor in self._processors:
|
||||||
processor.async_handle_update(update)
|
processor.async_handle_update(update, was_available)
|
||||||
|
|
||||||
|
|
||||||
_PassiveBluetoothDataProcessorT = TypeVar(
|
_PassiveBluetoothDataProcessorT = TypeVar(
|
||||||
@ -515,20 +517,39 @@ class PassiveBluetoothDataProcessor(Generic[_T]):
|
|||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_update_listeners(
|
def async_update_listeners(
|
||||||
self, data: PassiveBluetoothDataUpdate[_T] | None
|
self,
|
||||||
|
data: PassiveBluetoothDataUpdate[_T] | None,
|
||||||
|
was_available: bool | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update all registered listeners."""
|
"""Update all registered listeners."""
|
||||||
|
if was_available is None:
|
||||||
|
was_available = self.coordinator.available
|
||||||
|
|
||||||
# Dispatch to listeners without a filter key
|
# Dispatch to listeners without a filter key
|
||||||
for update_callback in self._listeners:
|
for update_callback in self._listeners:
|
||||||
update_callback(data)
|
update_callback(data)
|
||||||
|
|
||||||
|
if not was_available or data is None:
|
||||||
|
# When data is None, or was_available is False,
|
||||||
|
# dispatch to all listeners as it means the device
|
||||||
|
# is flipping between available and unavailable
|
||||||
|
for listeners in self._entity_key_listeners.values():
|
||||||
|
for update_callback in listeners:
|
||||||
|
update_callback(data)
|
||||||
|
return
|
||||||
|
|
||||||
# Dispatch to listeners with a filter key
|
# Dispatch to listeners with a filter key
|
||||||
for listeners in self._entity_key_listeners.values():
|
# if the key is in the data
|
||||||
for update_callback in listeners:
|
entity_key_listeners = self._entity_key_listeners
|
||||||
update_callback(data)
|
for entity_key in data.entity_data:
|
||||||
|
if maybe_listener := entity_key_listeners.get(entity_key):
|
||||||
|
for update_callback in maybe_listener:
|
||||||
|
update_callback(data)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_handle_update(self, update: _T) -> None:
|
def async_handle_update(
|
||||||
|
self, update: _T, was_available: bool | None = None
|
||||||
|
) -> None:
|
||||||
"""Handle a Bluetooth event."""
|
"""Handle a Bluetooth event."""
|
||||||
try:
|
try:
|
||||||
new_data = self.update_method(update)
|
new_data = self.update_method(update)
|
||||||
@ -553,7 +574,7 @@ class PassiveBluetoothDataProcessor(Generic[_T]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.data.update(new_data)
|
self.data.update(new_data)
|
||||||
self.async_update_listeners(new_data)
|
self.async_update_listeners(new_data, was_available)
|
||||||
|
|
||||||
|
|
||||||
class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProcessorT]):
|
class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProcessorT]):
|
||||||
|
@ -39,6 +39,8 @@ class BasePassiveBluetoothCoordinator(ABC):
|
|||||||
self.mode = mode
|
self.mode = mode
|
||||||
self._last_unavailable_time = 0.0
|
self._last_unavailable_time = 0.0
|
||||||
self._last_name = address
|
self._last_name = address
|
||||||
|
# Subclasses are responsible for setting _available to True
|
||||||
|
# when the abstractmethod _async_handle_bluetooth_event is called.
|
||||||
self._available = async_address_present(hass, address, connectable)
|
self._available = async_address_present(hass, address, connectable)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
@ -88,23 +90,13 @@ class BasePassiveBluetoothCoordinator(ABC):
|
|||||||
"""Return if the device is available."""
|
"""Return if the device is available."""
|
||||||
return self._available
|
return self._available
|
||||||
|
|
||||||
@callback
|
|
||||||
def _async_handle_bluetooth_event_internal(
|
|
||||||
self,
|
|
||||||
service_info: BluetoothServiceInfoBleak,
|
|
||||||
change: BluetoothChange,
|
|
||||||
) -> None:
|
|
||||||
"""Handle a bluetooth event."""
|
|
||||||
self._available = True
|
|
||||||
self._async_handle_bluetooth_event(service_info, change)
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_start(self) -> None:
|
def _async_start(self) -> None:
|
||||||
"""Start the callbacks."""
|
"""Start the callbacks."""
|
||||||
self._on_stop.append(
|
self._on_stop.append(
|
||||||
async_register_callback(
|
async_register_callback(
|
||||||
self.hass,
|
self.hass,
|
||||||
self._async_handle_bluetooth_event_internal,
|
self._async_handle_bluetooth_event,
|
||||||
BluetoothCallbackMatcher(
|
BluetoothCallbackMatcher(
|
||||||
address=self.address, connectable=self.connectable
|
address=self.address, connectable=self.connectable
|
||||||
),
|
),
|
||||||
|
@ -91,7 +91,7 @@ async def test_basic_usage(
|
|||||||
# The first time, it was passed the data from parsing the advertisement
|
# The first time, it was passed the data from parsing the advertisement
|
||||||
# The second time, it was passed the data from polling
|
# The second time, it was passed the data from polling
|
||||||
assert len(async_handle_update.mock_calls) == 2
|
assert len(async_handle_update.mock_calls) == 2
|
||||||
assert async_handle_update.mock_calls[0] == call({"testdata": 0})
|
assert async_handle_update.mock_calls[0] == call({"testdata": 0}, False)
|
||||||
assert async_handle_update.mock_calls[1] == call({"testdata": 1})
|
assert async_handle_update.mock_calls[1] == call({"testdata": 1})
|
||||||
|
|
||||||
cancel()
|
cancel()
|
||||||
@ -148,7 +148,7 @@ async def test_poll_can_be_skipped(
|
|||||||
|
|
||||||
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2)
|
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert async_handle_update.mock_calls[-1] == call({"testdata": None})
|
assert async_handle_update.mock_calls[-1] == call({"testdata": None}, True)
|
||||||
|
|
||||||
flag = True
|
flag = True
|
||||||
|
|
||||||
@ -208,7 +208,7 @@ async def test_bleak_error_and_recover(
|
|||||||
# First poll fails
|
# First poll fails
|
||||||
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO)
|
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert async_handle_update.mock_calls[-1] == call({"testdata": None})
|
assert async_handle_update.mock_calls[-1] == call({"testdata": None}, False)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
"aa:bb:cc:dd:ee:ff: Bluetooth error whilst polling: Connection was aborted"
|
"aa:bb:cc:dd:ee:ff: Bluetooth error whilst polling: Connection was aborted"
|
||||||
@ -272,7 +272,7 @@ async def test_poll_failure_and_recover(
|
|||||||
# First poll fails
|
# First poll fails
|
||||||
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO)
|
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert async_handle_update.mock_calls[-1] == call({"testdata": None})
|
assert async_handle_update.mock_calls[-1] == call({"testdata": None}, False)
|
||||||
|
|
||||||
# Second poll works
|
# Second poll works
|
||||||
flag = False
|
flag = False
|
||||||
@ -433,7 +433,7 @@ async def test_no_polling_after_stop_event(
|
|||||||
# The first time, it was passed the data from parsing the advertisement
|
# The first time, it was passed the data from parsing the advertisement
|
||||||
# The second time, it was passed the data from polling
|
# The second time, it was passed the data from polling
|
||||||
assert len(async_handle_update.mock_calls) == 2
|
assert len(async_handle_update.mock_calls) == 2
|
||||||
assert async_handle_update.mock_calls[0] == call({"testdata": 0})
|
assert async_handle_update.mock_calls[0] == call({"testdata": 0}, False)
|
||||||
assert async_handle_update.mock_calls[1] == call({"testdata": 1})
|
assert async_handle_update.mock_calls[1] == call({"testdata": 1})
|
||||||
|
|
||||||
hass.state = CoreState.stopping
|
hass.state = CoreState.stopping
|
||||||
|
@ -858,22 +858,49 @@ async def test_integration_with_entity(
|
|||||||
mock_add_entities,
|
mock_add_entities,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
entity_key_events = []
|
||||||
|
|
||||||
|
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,
|
||||||
|
PassiveBluetoothEntityKey(key="humidity", device_id="primary"),
|
||||||
|
)
|
||||||
|
|
||||||
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO)
|
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO)
|
||||||
# First call with just the remote sensor entities results in them being added
|
# First call with just the remote sensor entities results in them being added
|
||||||
assert len(mock_add_entities.mock_calls) == 1
|
assert len(mock_add_entities.mock_calls) == 1
|
||||||
|
|
||||||
|
# should have triggered the entity key listener since the
|
||||||
|
# the device is becoming available
|
||||||
|
assert len(entity_key_events) == 1
|
||||||
|
|
||||||
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2)
|
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2)
|
||||||
# Second call with just the remote sensor entities does not add them again
|
# Second call with just the remote sensor entities does not add them again
|
||||||
assert len(mock_add_entities.mock_calls) == 1
|
assert len(mock_add_entities.mock_calls) == 1
|
||||||
|
|
||||||
|
# should not have triggered the entity key listener since there
|
||||||
|
# there is no update with the entity key
|
||||||
|
assert len(entity_key_events) == 1
|
||||||
|
|
||||||
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO)
|
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO)
|
||||||
# Third call with primary and remote sensor entities adds the primary sensor entities
|
# Third call with primary and remote sensor entities adds the primary sensor entities
|
||||||
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
|
||||||
|
# there is an update with the entity key
|
||||||
|
assert len(entity_key_events) == 2
|
||||||
|
|
||||||
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2)
|
inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2)
|
||||||
# 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
|
||||||
|
# there is an update with the entity key
|
||||||
|
assert len(entity_key_events) == 3
|
||||||
|
|
||||||
entities = [
|
entities = [
|
||||||
*mock_add_entities.mock_calls[0][1][0],
|
*mock_add_entities.mock_calls[0][1][0],
|
||||||
*mock_add_entities.mock_calls[1][1][0],
|
*mock_add_entities.mock_calls[1][1][0],
|
||||||
@ -892,6 +919,7 @@ async def test_integration_with_entity(
|
|||||||
assert entity_one.entity_key == PassiveBluetoothEntityKey(
|
assert entity_one.entity_key == PassiveBluetoothEntityKey(
|
||||||
key="temperature", device_id="remote"
|
key="temperature", device_id="remote"
|
||||||
)
|
)
|
||||||
|
cancel_async_add_entity_key_listener()
|
||||||
cancel_coordinator()
|
cancel_coordinator()
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user