Add support for sleepy Xiaomi BLE sensors (#97166)

pull/97172/head
Ernst Klamer 2023-07-24 21:13:16 +02:00 committed by GitHub
parent 8ff9f2ddbe
commit 28197adebd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 242 additions and 27 deletions

View File

@ -12,15 +12,18 @@ from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_ble_device_from_address,
)
from homeassistant.components.bluetooth.active_update_processor import (
ActiveBluetoothProcessorCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.helpers.device_registry import DeviceRegistry, async_get
from .const import DOMAIN, XIAOMI_BLE_EVENT, XiaomiBleEvent
from .const import (
CONF_DISCOVERED_EVENT_CLASSES,
DOMAIN,
XIAOMI_BLE_EVENT,
XiaomiBleEvent,
)
from .coordinator import XiaomiActiveBluetoothProcessorCoordinator
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
@ -36,6 +39,10 @@ def process_service_info(
) -> SensorUpdate:
"""Process a BluetoothServiceInfoBleak, running side effects and returning sensor data."""
update = data.update(service_info)
coordinator: XiaomiActiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
entry.entry_id
]
discovered_device_classes = coordinator.discovered_device_classes
if update.events:
address = service_info.device.address
for device_key, event in update.events.items():
@ -49,6 +56,16 @@ def process_service_info(
sw_version=sensor_device_info.sw_version,
hw_version=sensor_device_info.hw_version,
)
event_class = event.device_key.key
event_type = event.event_type
if event_class not in discovered_device_classes:
discovered_device_classes.add(event_class)
hass.config_entries.async_update_entry(
entry,
data=entry.data
| {CONF_DISCOVERED_EVENT_CLASSES: list(discovered_device_classes)},
)
hass.bus.async_fire(
XIAOMI_BLE_EVENT,
@ -56,7 +73,8 @@ def process_service_info(
XiaomiBleEvent(
device_id=device.id,
address=address,
event_type=event.event_type,
event_class=event_class, # ie 'button'
event_type=event_type, # ie 'press'
event_properties=event.event_properties,
)
),
@ -121,7 +139,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
device_registry = async_get(hass)
coordinator = hass.data.setdefault(DOMAIN, {})[
entry.entry_id
] = ActiveBluetoothProcessorCoordinator(
] = XiaomiActiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
address=address,
@ -130,6 +148,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass, entry, data, service_info, device_registry
),
needs_poll_method=_needs_poll,
device_data=data,
discovered_device_classes=set(
entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, [])
),
poll_method=_async_poll,
# We will take advertisements from non-connectable devices
# since we will trade the BLEDevice for a connectable one

View File

@ -1,7 +1,6 @@
"""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,
@ -15,17 +14,18 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate,
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
from .const import DOMAIN
from .coordinator import (
XiaomiActiveBluetoothProcessorCoordinator,
XiaomiPassiveBluetoothDataProcessor,
)
from .device import device_key_to_bluetooth_entity_key
BINARY_SENSOR_DESCRIPTIONS = {
@ -108,10 +108,12 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Xiaomi BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
coordinator: XiaomiActiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
entry.entry_id
]
processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update)
processor = XiaomiPassiveBluetoothDataProcessor(
sensor_update_to_bluetooth_data_update
)
entry.async_on_unload(
processor.async_add_entities_listener(
XiaomiBluetoothSensorEntity, async_add_entities
@ -121,7 +123,7 @@ async def async_setup_entry(
class XiaomiBluetoothSensorEntity(
PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[bool | None]],
PassiveBluetoothProcessorEntity[XiaomiPassiveBluetoothDataProcessor],
BinarySensorEntity,
):
"""Representation of a Xiaomi binary sensor."""
@ -134,8 +136,7 @@ class XiaomiBluetoothSensorEntity(
@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
coordinator: XiaomiActiveBluetoothProcessorCoordinator = (
self.processor.coordinator
)
return coordinator.device_data.sleepy_device or super().available

View File

@ -6,6 +6,7 @@ from typing import Final, TypedDict
DOMAIN = "xiaomi_ble"
CONF_DISCOVERED_EVENT_CLASSES: Final = "known_events"
CONF_EVENT_PROPERTIES: Final = "event_properties"
EVENT_PROPERTIES: Final = "event_properties"
EVENT_TYPE: Final = "event_type"
@ -17,5 +18,6 @@ class XiaomiBleEvent(TypedDict):
device_id: str
address: str
event_type: str
event_class: str # ie 'button'
event_type: str # ie 'press'
event_properties: dict[str, str | int | float | None] | None

View File

@ -0,0 +1,63 @@
"""The Xiaomi BLE integration."""
from collections.abc import Callable, Coroutine
from logging import Logger
from typing import Any
from xiaomi_ble import XiaomiBluetoothDeviceData
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
BluetoothServiceInfoBleak,
)
from homeassistant.components.bluetooth.active_update_processor import (
ActiveBluetoothProcessorCoordinator,
)
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataProcessor,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.debounce import Debouncer
class XiaomiActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordinator):
"""Define a Xiaomi Bluetooth Active Update Processor Coordinator."""
def __init__(
self,
hass: HomeAssistant,
logger: Logger,
*,
address: str,
mode: BluetoothScanningMode,
update_method: Callable[[BluetoothServiceInfoBleak], Any],
needs_poll_method: Callable[[BluetoothServiceInfoBleak, float | None], bool],
device_data: XiaomiBluetoothDeviceData,
discovered_device_classes: set[str],
poll_method: Callable[
[BluetoothServiceInfoBleak],
Coroutine[Any, Any, Any],
]
| None = None,
poll_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None,
connectable: bool = True,
) -> None:
"""Initialize the Xiaomi Bluetooth Active Update Processor Coordinator."""
super().__init__(
hass=hass,
logger=logger,
address=address,
mode=mode,
update_method=update_method,
needs_poll_method=needs_poll_method,
poll_method=poll_method,
poll_debouncer=poll_debouncer,
connectable=connectable,
)
self.discovered_device_classes = discovered_device_classes
self.device_data = device_data
class XiaomiPassiveBluetoothDataProcessor(PassiveBluetoothDataProcessor):
"""Define a Xiaomi Bluetooth Passive Update Data Processor."""
coordinator: XiaomiActiveBluetoothProcessorCoordinator

View File

@ -5,9 +5,7 @@ from xiaomi_ble import DeviceClass, SensorUpdate, Units
from homeassistant import config_entries
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate,
PassiveBluetoothProcessorCoordinator,
PassiveBluetoothProcessorEntity,
)
from homeassistant.components.sensor import (
@ -33,6 +31,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .const import DOMAIN
from .coordinator import (
XiaomiActiveBluetoothProcessorCoordinator,
XiaomiPassiveBluetoothDataProcessor,
)
from .device import device_key_to_bluetooth_entity_key
SENSOR_DESCRIPTIONS = {
@ -170,10 +172,12 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Xiaomi BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
coordinator: XiaomiActiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
entry.entry_id
]
processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update)
processor = XiaomiPassiveBluetoothDataProcessor(
sensor_update_to_bluetooth_data_update
)
entry.async_on_unload(
processor.async_add_entities_listener(
XiaomiBluetoothSensorEntity, async_add_entities
@ -183,7 +187,7 @@ async def async_setup_entry(
class XiaomiBluetoothSensorEntity(
PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]],
PassiveBluetoothProcessorEntity[XiaomiPassiveBluetoothDataProcessor],
SensorEntity,
):
"""Representation of a xiaomi ble sensor."""
@ -192,3 +196,11 @@ class XiaomiBluetoothSensorEntity(
def native_value(self) -> int | float | 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."""
coordinator: XiaomiActiveBluetoothProcessorCoordinator = (
self.processor.coordinator
)
return coordinator.device_data.sleepy_device or super().available

View File

@ -1,8 +1,20 @@
"""Test Xiaomi BLE 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.sensor import ATTR_STATE_CLASS
from homeassistant.components.xiaomi_ble.const import DOMAIN
from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT
from homeassistant.const import (
ATTR_FRIENDLY_NAME,
ATTR_UNIT_OF_MEASUREMENT,
STATE_UNAVAILABLE,
)
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from . import (
HHCCJCY10_SERVICE_INFO,
@ -12,8 +24,11 @@ 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_sensors(hass: HomeAssistant) -> None:
@ -610,3 +625,103 @@ async def test_miscale_v2_uuid(hass: HomeAssistant) -> None:
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
async def test_unavailable(hass: HomeAssistant) -> None:
"""Test normal device goes to unavailable after 60 minutes."""
start_monotonic = time.monotonic()
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="58:2D:34:12:20:89",
data={"bindkey": "a3bfe9853dd85a620debe3620caaa351"},
)
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(
"58:2D:34:12:20:89",
b"XXo\x06\x07\x89 \x124-X_\x17m\xd5O\x02\x00\x00/\xa4S\xfa",
),
)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 1
temp_sensor = hass.states.get("sensor.temperature_humidity_sensor_2089_temperature")
assert temp_sensor.state == "22.6"
# 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()
temp_sensor = hass.states.get("sensor.temperature_humidity_sensor_2089_temperature")
# Sleepy devices should keep their state over time
assert temp_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: HomeAssistant) -> None:
"""Test normal device goes to unavailable after 60 minutes."""
start_monotonic = time.monotonic()
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="50:FB:19:1B:B5:DC",
)
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, MISCALE_V1_SERVICE_INFO)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 2
mass_non_stabilized_sensor = hass.states.get(
"sensor.mi_smart_scale_b5dc_mass_non_stabilized"
)
assert mass_non_stabilized_sensor.state == "86.55"
# 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()
mass_non_stabilized_sensor = hass.states.get(
"sensor.mi_smart_scale_b5dc_mass_non_stabilized"
)
# Sleepy devices should keep their state over time
assert mass_non_stabilized_sensor.state == "86.55"
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()