From 151b33a4ab6e781137aee957eb2a6da6ae83f325 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Thu, 9 Feb 2023 23:42:08 +0100 Subject: [PATCH] Keep sleepy xiaomi-ble devices that don't broadcast regularly available (#87654) Co-authored-by: J. Nick Koston fixes undefined --- .../components/xiaomi_ble/__init__.py | 3 +- .../components/xiaomi_ble/binary_sensor.py | 11 ++ .../components/xiaomi_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../xiaomi_ble/test_binary_sensor.py | 149 ++++++++++++++++-- 6 files changed, 149 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/__init__.py b/homeassistant/components/xiaomi_ble/__init__.py index 372afe4b3c5..3930c50c70c 100644 --- a/homeassistant/components/xiaomi_ble/__init__.py +++ b/homeassistant/components/xiaomi_ble/__init__.py @@ -3,8 +3,7 @@ from __future__ import annotations import logging -from xiaomi_ble import SensorUpdate, XiaomiBluetoothDeviceData -from xiaomi_ble.parser import EncryptionScheme +from xiaomi_ble import EncryptionScheme, SensorUpdate, XiaomiBluetoothDeviceData from homeassistant import config_entries from homeassistant.components.bluetooth import ( diff --git a/homeassistant/components/xiaomi_ble/binary_sensor.py b/homeassistant/components/xiaomi_ble/binary_sensor.py index 1de3afff53f..3d7bdfd0b48 100644 --- a/homeassistant/components/xiaomi_ble/binary_sensor.py +++ b/homeassistant/components/xiaomi_ble/binary_sensor.py @@ -1,6 +1,7 @@ """Support for Xiaomi binary sensors.""" from __future__ import annotations +from xiaomi_ble import SLEEPY_DEVICE_MODELS from xiaomi_ble.parser import ( BinarySensorDeviceClass as XiaomiBinarySensorDeviceClass, ExtendedBinarySensorDeviceClass, @@ -19,6 +20,7 @@ from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) +from homeassistant.const import ATTR_MODEL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info @@ -128,3 +130,12 @@ class XiaomiBluetoothSensorEntity( def is_on(self) -> bool | None: """Return the native value.""" return self.processor.entity_data.get(self.entity_key) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.device_info and self.device_info[ATTR_MODEL] in SLEEPY_DEVICE_MODELS: + # These devices sleep for an indeterminate amount of time + # so there is no way to track their availability. + return True + return super().available diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index f3904003411..9fb35db3248 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -14,7 +14,7 @@ } ], "dependencies": ["bluetooth_adapters"], - "requirements": ["xiaomi-ble==0.16.1"], + "requirements": ["xiaomi-ble==0.16.3"], "codeowners": ["@Jc2k", "@Ernst79"], "iot_class": "local_push" } diff --git a/requirements_all.txt b/requirements_all.txt index e6450ee8b0b..b046166e4f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2637,7 +2637,7 @@ xbox-webapi==2.0.11 xboxapi==2.0.1 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.16.1 +xiaomi-ble==0.16.3 # homeassistant.components.knx xknx==2.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d82e4e5547e..b6af545e759 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1862,7 +1862,7 @@ wolf_smartset==0.1.11 xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.16.1 +xiaomi-ble==0.16.3 # homeassistant.components.knx xknx==2.3.0 diff --git a/tests/components/xiaomi_ble/test_binary_sensor.py b/tests/components/xiaomi_ble/test_binary_sensor.py index 5389a2987f2..dae1569ff31 100644 --- a/tests/components/xiaomi_ble/test_binary_sensor.py +++ b/tests/components/xiaomi_ble/test_binary_sensor.py @@ -1,12 +1,28 @@ """Test Xiaomi binary sensors.""" +from datetime import timedelta +import time +from unittest.mock import patch + +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) from homeassistant.components.xiaomi_ble.const import DOMAIN -from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.util import dt as dt_util from . import make_advertisement -from tests.common import MockConfigEntry -from tests.components.bluetooth import inject_bluetooth_service_info_bleak +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.bluetooth import ( + inject_bluetooth_service_info_bleak, + patch_all_discovered_devices, +) async def test_door_problem_sensors(hass): @@ -34,19 +50,19 @@ async def test_door_problem_sensors(hass): door_sensor = hass.states.get("binary_sensor.door_lock_be98_door") door_sensor_attribtes = door_sensor.attributes - assert door_sensor.state == "off" + assert door_sensor.state == STATE_OFF assert door_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Door Lock BE98 Door" door_left_open = hass.states.get("binary_sensor.door_lock_be98_door_left_open") door_left_open_attribtes = door_left_open.attributes - assert door_left_open.state == "off" + assert door_left_open.state == STATE_OFF assert ( door_left_open_attribtes[ATTR_FRIENDLY_NAME] == "Door Lock BE98 Door left open" ) pry_the_door = hass.states.get("binary_sensor.door_lock_be98_pry_the_door") pry_the_door_attribtes = pry_the_door.attributes - assert pry_the_door.state == "off" + assert pry_the_door.state == STATE_OFF assert pry_the_door_attribtes[ATTR_FRIENDLY_NAME] == "Door Lock BE98 Pry the door" assert await hass.config_entries.async_unload(entry.entry_id) @@ -77,12 +93,12 @@ async def test_light_motion(hass): motion_sensor = hass.states.get("binary_sensor.nightlight_9321_motion") motion_sensor_attribtes = motion_sensor.attributes - assert motion_sensor.state == "on" + assert motion_sensor.state == STATE_ON assert motion_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Nightlight 9321 Motion" light_sensor = hass.states.get("binary_sensor.nightlight_9321_light") light_sensor_attribtes = light_sensor.attributes - assert light_sensor.state == "off" + assert light_sensor.state == STATE_OFF assert light_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Nightlight 9321 Light" assert await hass.config_entries.async_unload(entry.entry_id) @@ -116,7 +132,7 @@ async def test_moisture(hass): sensor = hass.states.get("binary_sensor.smart_flower_pot_3e7a_moisture") sensor_attr = sensor.attributes - assert sensor.state == "on" + assert sensor.state == STATE_ON assert sensor_attr[ATTR_FRIENDLY_NAME] == "Smart Flower Pot 3E7A Moisture" assert await hass.config_entries.async_unload(entry.entry_id) @@ -148,12 +164,12 @@ async def test_opening(hass): opening_sensor = hass.states.get("binary_sensor.door_window_sensor_e567_opening") opening_sensor_attribtes = opening_sensor.attributes - assert opening_sensor.state == "on" + + assert opening_sensor.state == STATE_ON assert ( opening_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Door/Window Sensor E567 Opening" ) - assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @@ -183,7 +199,7 @@ async def test_opening_problem_sensors(hass): opening_sensor = hass.states.get("binary_sensor.door_window_sensor_e567_opening") opening_sensor_attribtes = opening_sensor.attributes - assert opening_sensor.state == "off" + assert opening_sensor.state == STATE_OFF assert ( opening_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Door/Window Sensor E567 Opening" @@ -193,7 +209,7 @@ async def test_opening_problem_sensors(hass): "binary_sensor.door_window_sensor_e567_door_left_open" ) door_left_open_attribtes = door_left_open.attributes - assert door_left_open.state == "off" + assert door_left_open.state == STATE_OFF assert ( door_left_open_attribtes[ATTR_FRIENDLY_NAME] == "Door/Window Sensor E567 Door left open" @@ -203,7 +219,7 @@ async def test_opening_problem_sensors(hass): "binary_sensor.door_window_sensor_e567_device_forcibly_removed" ) device_forcibly_removed_attribtes = device_forcibly_removed.attributes - assert device_forcibly_removed.state == "off" + assert device_forcibly_removed.state == STATE_OFF assert ( device_forcibly_removed_attribtes[ATTR_FRIENDLY_NAME] == "Door/Window Sensor E567 Device forcibly removed" @@ -238,8 +254,111 @@ async def test_smoke(hass): smoke_sensor = hass.states.get("binary_sensor.thermometer_9cbc_smoke") smoke_sensor_attribtes = smoke_sensor.attributes - assert smoke_sensor.state == "on" + assert smoke_sensor.state == STATE_ON assert smoke_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Thermometer 9CBC Smoke" assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_unavailable(hass): + """Test normal device goes to unavailable after 60 minutes.""" + start_monotonic = time.monotonic() + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="A4:C1:38:66:E5:67", + data={"bindkey": "0fdcc30fe9289254876b5ef7c11ef1f0"}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + "A4:C1:38:66:E5:67", + b"XY\x89\x18\x9ag\xe5f8\xc1\xa4\x9d\xd9z\xf3&\x00\x00\xc8\xa6\x0b\xd5", + ), + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + opening_sensor = hass.states.get("binary_sensor.door_window_sensor_e567_opening") + + assert opening_sensor.state == STATE_ON + + # Fastforward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + opening_sensor = hass.states.get("binary_sensor.door_window_sensor_e567_opening") + + # Normal devices should go to unavailable + assert opening_sensor.state == STATE_UNAVAILABLE + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_sleepy_device(hass): + """Test sleepy device does not go to unavailable after 60 minutes.""" + start_monotonic = time.monotonic() + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="A4:C1:38:66:E5:67", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + "A4:C1:38:66:E5:67", + b"@0\xd6\x03$\x19\x10\x01\x00", + ), + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + opening_sensor = hass.states.get("binary_sensor.door_window_sensor_e567_opening") + + assert opening_sensor.state == STATE_ON + + # Fastforward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + opening_sensor = hass.states.get("binary_sensor.door_window_sensor_e567_opening") + + # Sleepy devices should keep their state over time + assert opening_sensor.state == STATE_ON + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done()