diff --git a/homeassistant/components/bthome/__init__.py b/homeassistant/components/bthome/__init__.py index 1255def44cb..3e2e17a9a21 100644 --- a/homeassistant/components/bthome/__init__.py +++ b/homeassistant/components/bthome/__init__.py @@ -11,9 +11,6 @@ from homeassistant.components.bluetooth import ( BluetoothScanningMode, BluetoothServiceInfoBleak, ) -from homeassistant.components.bluetooth.passive_update_processor import ( - PassiveBluetoothProcessorCoordinator, -) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -26,7 +23,7 @@ from .const import ( DOMAIN, BTHomeBleEvent, ) -from .models import BTHomeData +from .coordinator import BTHomePassiveBluetoothProcessorCoordinator PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -42,7 +39,10 @@ def process_service_info( ) -> SensorUpdate: """Process a BluetoothServiceInfoBleak, running side effects and returning sensor data.""" update = data.update(service_info) - domain_data: BTHomeData = hass.data[DOMAIN][entry.entry_id] + coordinator: BTHomePassiveBluetoothProcessorCoordinator = 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(): @@ -59,16 +59,12 @@ def process_service_info( event_class = event.device_key.key event_type = event.event_type - if event_class not in domain_data.discovered_event_classes: - domain_data.discovered_event_classes.add(event_class) + 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( - domain_data.discovered_event_classes - ) - }, + | {CONF_DISCOVERED_EVENT_CLASSES: list(discovered_device_classes)}, ) hass.bus.async_fire( @@ -104,7 +100,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_registry = async_get(hass) coordinator = hass.data.setdefault(DOMAIN, {})[ entry.entry_id - ] = PassiveBluetoothProcessorCoordinator( + ] = BTHomePassiveBluetoothProcessorCoordinator( hass, _LOGGER, address=address, @@ -112,11 +108,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_method=lambda service_info: process_service_info( hass, entry, data, service_info, device_registry ), + device_data=data, + discovered_device_classes=set( + entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []) + ), connectable=False, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - domain_data = BTHomeData(set(entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []))) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = domain_data entry.async_on_unload( coordinator.async_start() diff --git a/homeassistant/components/bthome/binary_sensor.py b/homeassistant/components/bthome/binary_sensor.py index ad36fe3644e..d9d24e95007 100644 --- a/homeassistant/components/bthome/binary_sensor.py +++ b/homeassistant/components/bthome/binary_sensor.py @@ -13,9 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.components.bluetooth.passive_update_processor import ( - PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.core import HomeAssistant @@ -23,6 +21,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 ( + BTHomePassiveBluetoothDataProcessor, + BTHomePassiveBluetoothProcessorCoordinator, +) from .device import device_key_to_bluetooth_entity_key BINARY_SENSOR_DESCRIPTIONS = { @@ -173,10 +175,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the BTHome BLE binary sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] - processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) + processor = BTHomePassiveBluetoothDataProcessor( + sensor_update_to_bluetooth_data_update + ) entry.async_on_unload( processor.async_add_entities_listener( BTHomeBluetoothBinarySensorEntity, async_add_entities @@ -186,7 +190,7 @@ async def async_setup_entry( class BTHomeBluetoothBinarySensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[bool | None]], + PassiveBluetoothProcessorEntity[BTHomePassiveBluetoothDataProcessor], BinarySensorEntity, ): """Representation of a BTHome binary sensor.""" @@ -195,3 +199,11 @@ class BTHomeBluetoothBinarySensorEntity( 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.""" + coordinator: BTHomePassiveBluetoothProcessorCoordinator = ( + self.processor.coordinator + ) + return coordinator.device_data.sleepy_device or super().available diff --git a/homeassistant/components/bthome/coordinator.py b/homeassistant/components/bthome/coordinator.py new file mode 100644 index 00000000000..dafa932a73e --- /dev/null +++ b/homeassistant/components/bthome/coordinator.py @@ -0,0 +1,42 @@ +"""The BTHome Bluetooth integration.""" +from collections.abc import Callable +from logging import Logger +from typing import Any + +from bthome_ble import BTHomeBluetoothDeviceData + +from homeassistant.components.bluetooth import ( + BluetoothScanningMode, + BluetoothServiceInfoBleak, +) +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothDataProcessor, + PassiveBluetoothProcessorCoordinator, +) +from homeassistant.core import HomeAssistant + + +class BTHomePassiveBluetoothProcessorCoordinator(PassiveBluetoothProcessorCoordinator): + """Define a BTHome Bluetooth Passive Update Processor Coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + logger: Logger, + address: str, + mode: BluetoothScanningMode, + update_method: Callable[[BluetoothServiceInfoBleak], Any], + device_data: BTHomeBluetoothDeviceData, + discovered_device_classes: set[str], + connectable: bool = False, + ) -> None: + """Initialize the BTHome Bluetooth Passive Update Processor Coordinator.""" + super().__init__(hass, logger, address, mode, update_method, connectable) + self.discovered_device_classes = discovered_device_classes + self.device_data = device_data + + +class BTHomePassiveBluetoothDataProcessor(PassiveBluetoothDataProcessor): + """Define a BTHome Bluetooth Passive Update Data Processor.""" + + coordinator: BTHomePassiveBluetoothProcessorCoordinator diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 87a84e5fab0..ef3d9bc002d 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==2.9.0"] + "requirements": ["bthome-ble==2.11.3"] } diff --git a/homeassistant/components/bthome/models.py b/homeassistant/components/bthome/models.py deleted file mode 100644 index 558f19c7742..00000000000 --- a/homeassistant/components/bthome/models.py +++ /dev/null @@ -1,11 +0,0 @@ -"""The bthome integration models.""" -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass -class BTHomeData: - """Data for the bthome integration.""" - - discovered_event_classes: set[str] diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 9b5def30054..f8693c5fb34 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -5,9 +5,7 @@ from bthome_ble import SensorDeviceClass as BTHomeSensorDeviceClass, SensorUpdat from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( - PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -42,6 +40,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 ( + BTHomePassiveBluetoothDataProcessor, + BTHomePassiveBluetoothProcessorCoordinator, +) from .device import device_key_to_bluetooth_entity_key SENSOR_DESCRIPTIONS = { @@ -343,10 +345,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the BTHome BLE sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] - processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) + processor = BTHomePassiveBluetoothDataProcessor( + sensor_update_to_bluetooth_data_update + ) entry.async_on_unload( processor.async_add_entities_listener( BTHomeBluetoothSensorEntity, async_add_entities @@ -356,7 +360,7 @@ async def async_setup_entry( class BTHomeBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + PassiveBluetoothProcessorEntity[BTHomePassiveBluetoothDataProcessor], SensorEntity, ): """Representation of a BTHome BLE sensor.""" @@ -365,3 +369,11 @@ class BTHomeBluetoothSensorEntity( 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: BTHomePassiveBluetoothProcessorCoordinator = ( + self.processor.coordinator + ) + return coordinator.device_data.sleepy_device or super().available diff --git a/requirements_all.txt b/requirements_all.txt index 2445c2752f2..4094cc2acc5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -499,7 +499,7 @@ brunt==1.2.0 bt_proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==2.9.0 +bthome-ble==2.11.3 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f01508d3d2..05954c0f9e6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -412,7 +412,7 @@ brottsplatskartan==0.0.1 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==2.9.0 +bthome-ble==2.11.3 # homeassistant.components.buienradar buienradar==1.0.5 diff --git a/tests/components/bthome/test_binary_sensor.py b/tests/components/bthome/test_binary_sensor.py index 4905a7ef3fe..cc5ad13dc80 100644 --- a/tests/components/bthome/test_binary_sensor.py +++ b/tests/components/bthome/test_binary_sensor.py @@ -1,16 +1,31 @@ """Test BTHome binary sensors.""" +from datetime import timedelta import logging +import time +from unittest.mock import patch import pytest +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) from homeassistant.components.bthome.const import DOMAIN -from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF, STATE_ON +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from . import make_bthome_v1_adv, make_bthome_v2_adv -from tests.common import MockConfigEntry -from tests.components.bluetooth import inject_bluetooth_service_info +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.bluetooth import ( + inject_bluetooth_service_info, + patch_all_discovered_devices, +) _LOGGER = logging.getLogger(__name__) @@ -123,7 +138,7 @@ async def test_v1_binary_sensors( "A4:C1:38:8D:18:B2", make_bthome_v2_adv( "A4:C1:38:8D:18:B2", - b"\x40\x11\x00", + b"\x44\x11\x00", ), None, [ @@ -185,3 +200,111 @@ async def test_v2_binary_sensors( assert binary_sensor_attr[ATTR_FRIENDLY_NAME] == meas["friendly_name"] 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="A4:C1:38:8D:18:B2", + data={}, + ) + 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( + hass, + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x11\x01", + ), + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + opening_sensor = hass.states.get("binary_sensor.test_device_18b2_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.test_device_18b2_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: HomeAssistant) -> None: + """Test sleepy device does not go to unavailable after 60 minutes.""" + start_monotonic = time.monotonic() + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="A4:C1:38:8D:18:B2", + data={}, + ) + 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( + hass, + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x44\x11\x01", + ), + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + opening_sensor = hass.states.get("binary_sensor.test_device_18b2_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.test_device_18b2_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() diff --git a/tests/components/bthome/test_sensor.py b/tests/components/bthome/test_sensor.py index 7893ad3cb44..7aafe7ba7a9 100644 --- a/tests/components/bthome/test_sensor.py +++ b/tests/components/bthome/test_sensor.py @@ -1,17 +1,31 @@ """Test the BTHome sensors.""" +from datetime import timedelta import logging +import time +from unittest.mock import patch import pytest +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) from homeassistant.components.bthome.const import DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS -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 make_bthome_v1_adv, make_bthome_v2_adv, make_encrypted_bthome_v1_adv -from tests.common import MockConfigEntry -from tests.components.bluetooth import inject_bluetooth_service_info +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.bluetooth import ( + inject_bluetooth_service_info, + patch_all_discovered_devices, +) _LOGGER = logging.getLogger(__name__) @@ -518,7 +532,7 @@ async def test_v1_sensors( "A4:C1:38:8D:18:B2", make_bthome_v2_adv( "A4:C1:38:8D:18:B2", - b"\x40\x09\x60", + b"\x44\x09\x60", ), None, [ @@ -1008,9 +1022,7 @@ async def test_v2_sensors( assert len(hass.states.async_all()) == len(result) for meas in result: - _LOGGER.error(meas) sensor = hass.states.get(meas["sensor_entity"]) - _LOGGER.error(hass.states) sensor_attr = sensor.attributes assert sensor.state == meas["expected_state"] assert sensor_attr[ATTR_FRIENDLY_NAME] == meas["friendly_name"] @@ -1020,3 +1032,111 @@ async def test_v2_sensors( assert sensor_attr[ATTR_STATE_CLASS] == meas["state_class"] 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="A4:C1:38:8D:18:B2", + data={}, + ) + 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( + hass, + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x40\x04\x13\x8a\x01", + ), + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + pressure_sensor = hass.states.get("sensor.test_device_18b2_pressure") + + assert pressure_sensor.state == "1008.83" + + # 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() + + pressure_sensor = hass.states.get("sensor.test_device_18b2_pressure") + + # Normal devices should go to unavailable + assert pressure_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 sleepy device does not go to unavailable after 60 minutes.""" + start_monotonic = time.monotonic() + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="A4:C1:38:8D:18:B2", + data={}, + ) + 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( + hass, + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x44\x04\x13\x8a\x01", + ), + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + pressure_sensor = hass.states.get("sensor.test_device_18b2_pressure") + + assert pressure_sensor.state == "1008.83" + + # 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() + + pressure_sensor = hass.states.get("sensor.test_device_18b2_pressure") + + # Sleepy devices should keep their state over time + assert pressure_sensor.state == "1008.83" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done()