diff --git a/homeassistant/components/gardena_bluetooth/__init__.py b/homeassistant/components/gardena_bluetooth/__init__.py index 4a21bb3d3e4..9be802b09ae 100644 --- a/homeassistant/components/gardena_bluetooth/__init__.py +++ b/homeassistant/components/gardena_bluetooth/__init__.py @@ -2,12 +2,18 @@ from __future__ import annotations +import asyncio import logging from bleak.backends.device import BLEDevice from gardena_bluetooth.client import CachedConnection, Client from gardena_bluetooth.const import DeviceConfiguration, DeviceInformation -from gardena_bluetooth.exceptions import CommunicationFailure +from gardena_bluetooth.exceptions import ( + CharacteristicNoAccess, + CharacteristicNotFound, + CommunicationFailure, +) +from gardena_bluetooth.parse import CharacteristicTime from homeassistant.components import bluetooth from homeassistant.const import CONF_ADDRESS, Platform @@ -23,6 +29,7 @@ from .coordinator import ( GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator, ) +from .util import async_get_product_type PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -51,22 +58,41 @@ def get_connection(hass: HomeAssistant, address: str) -> CachedConnection: return CachedConnection(DISCONNECT_DELAY, _device_lookup) +async def _update_timestamp(client: Client, characteristics: CharacteristicTime): + try: + await client.update_timestamp(characteristics, dt_util.now()) + except CharacteristicNotFound: + pass + except CharacteristicNoAccess: + LOGGER.debug("No access to update internal time") + + async def async_setup_entry( hass: HomeAssistant, entry: GardenaBluetoothConfigEntry ) -> bool: """Set up Gardena Bluetooth from a config entry.""" address = entry.data[CONF_ADDRESS] - client = Client(get_connection(hass, address)) + try: + async with asyncio.timeout(TIMEOUT): + product_type = await async_get_product_type(hass, address) + except TimeoutError as exception: + raise ConfigEntryNotReady("Unable to find product type") from exception + + client = Client(get_connection(hass, address), product_type) + try: + chars = await client.get_all_characteristics() + sw_version = await client.read_char(DeviceInformation.firmware_version, None) manufacturer = await client.read_char(DeviceInformation.manufacturer_name, None) model = await client.read_char(DeviceInformation.model_number, None) - name = await client.read_char( - DeviceConfiguration.custom_device_name, entry.title - ) - uuids = await client.get_all_characteristics_uuid() - await client.update_timestamp(dt_util.now()) + + name = entry.title + name = await client.read_char(DeviceConfiguration.custom_device_name, name) + + await _update_timestamp(client, DeviceConfiguration.unix_timestamp) + except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception: await client.disconnect() raise ConfigEntryNotReady( @@ -83,7 +109,7 @@ async def async_setup_entry( ) coordinator = GardenaBluetoothCoordinator( - hass, entry, LOGGER, client, uuids, device, address + hass, entry, LOGGER, client, set(chars.keys()), device, address ) entry.runtime_data = coordinator diff --git a/homeassistant/components/gardena_bluetooth/binary_sensor.py b/homeassistant/components/gardena_bluetooth/binary_sensor.py index b41988afd8c..ae177c04611 100644 --- a/homeassistant/components/gardena_bluetooth/binary_sensor.py +++ b/homeassistant/components/gardena_bluetooth/binary_sensor.py @@ -34,14 +34,14 @@ class GardenaBluetoothBinarySensorEntityDescription(BinarySensorEntityDescriptio DESCRIPTIONS = ( GardenaBluetoothBinarySensorEntityDescription( - key=Valve.connected_state.uuid, + key=Valve.connected_state.unique_id, translation_key="valve_connected_state", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, char=Valve.connected_state, ), GardenaBluetoothBinarySensorEntityDescription( - key=Sensor.connected_state.uuid, + key=Sensor.connected_state.unique_id, translation_key="sensor_connected_state", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, @@ -60,7 +60,7 @@ async def async_setup_entry( entities = [ GardenaBluetoothBinarySensor(coordinator, description, description.context) for description in DESCRIPTIONS - if description.key in coordinator.characteristics + if description.char.unique_id in coordinator.characteristics ] async_add_entities(entities) diff --git a/homeassistant/components/gardena_bluetooth/button.py b/homeassistant/components/gardena_bluetooth/button.py index 6a4f0395fe0..1dda3717487 100644 --- a/homeassistant/components/gardena_bluetooth/button.py +++ b/homeassistant/components/gardena_bluetooth/button.py @@ -30,7 +30,7 @@ class GardenaBluetoothButtonEntityDescription(ButtonEntityDescription): DESCRIPTIONS = ( GardenaBluetoothButtonEntityDescription( - key=Reset.factory_reset.uuid, + key=Reset.factory_reset.unique_id, translation_key="factory_reset", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -49,7 +49,7 @@ async def async_setup_entry( entities = [ GardenaBluetoothButton(coordinator, description, description.context) for description in DESCRIPTIONS - if description.key in coordinator.characteristics + if description.char.unique_id in coordinator.characteristics ] async_add_entities(entities) diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index b3d0bd8257a..966a10bc9b0 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -15,5 +15,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"], - "requirements": ["gardena-bluetooth==1.6.0"] + "requirements": ["gardena-bluetooth==2.1.0"] } diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index 342061c18d1..c0c8824492c 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -46,7 +46,7 @@ class GardenaBluetoothNumberEntityDescription(NumberEntityDescription): DESCRIPTIONS = ( GardenaBluetoothNumberEntityDescription( - key=Valve.manual_watering_time.uuid, + key=Valve.manual_watering_time.unique_id, translation_key="manual_watering_time", native_unit_of_measurement=UnitOfTime.SECONDS, mode=NumberMode.BOX, @@ -58,7 +58,7 @@ DESCRIPTIONS = ( device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( - key=Valve.remaining_open_time.uuid, + key=Valve.remaining_open_time.unique_id, translation_key="remaining_open_time", native_unit_of_measurement=UnitOfTime.SECONDS, native_min_value=0.0, @@ -69,7 +69,7 @@ DESCRIPTIONS = ( device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( - key=DeviceConfiguration.rain_pause.uuid, + key=DeviceConfiguration.rain_pause.unique_id, translation_key="rain_pause", native_unit_of_measurement=UnitOfTime.MINUTES, mode=NumberMode.BOX, @@ -81,7 +81,7 @@ DESCRIPTIONS = ( device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( - key=DeviceConfiguration.seasonal_adjust.uuid, + key=DeviceConfiguration.seasonal_adjust.unique_id, translation_key="seasonal_adjust", native_unit_of_measurement=UnitOfTime.DAYS, mode=NumberMode.BOX, @@ -93,7 +93,7 @@ DESCRIPTIONS = ( device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( - key=Sensor.threshold.uuid, + key=Sensor.threshold.unique_id, translation_key="sensor_threshold", native_unit_of_measurement=PERCENTAGE, mode=NumberMode.BOX, @@ -117,9 +117,9 @@ async def async_setup_entry( entities: list[NumberEntity] = [ GardenaBluetoothNumber(coordinator, description, description.context) for description in DESCRIPTIONS - if description.key in coordinator.characteristics + if description.char.unique_id in coordinator.characteristics ] - if Valve.remaining_open_time.uuid in coordinator.characteristics: + if Valve.remaining_open_time.unique_id in coordinator.characteristics: entities.append(GardenaBluetoothRemainingOpenSetNumber(coordinator)) async_add_entities(entities) diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index 602f5bdfd6e..c491c1aac86 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -41,7 +41,7 @@ class GardenaBluetoothSensorEntityDescription(SensorEntityDescription): DESCRIPTIONS = ( GardenaBluetoothSensorEntityDescription( - key=Valve.activation_reason.uuid, + key=Valve.activation_reason.unique_id, translation_key="activation_reason", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -49,7 +49,7 @@ DESCRIPTIONS = ( char=Valve.activation_reason, ), GardenaBluetoothSensorEntityDescription( - key=Battery.battery_level.uuid, + key=Battery.battery_level.unique_id, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, @@ -57,7 +57,7 @@ DESCRIPTIONS = ( char=Battery.battery_level, ), GardenaBluetoothSensorEntityDescription( - key=Sensor.battery_level.uuid, + key=Sensor.battery_level.unique_id, translation_key="sensor_battery_level", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.BATTERY, @@ -67,7 +67,7 @@ DESCRIPTIONS = ( connected_state=Sensor.connected_state, ), GardenaBluetoothSensorEntityDescription( - key=Sensor.value.uuid, + key=Sensor.value.unique_id, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.MOISTURE, native_unit_of_measurement=PERCENTAGE, @@ -75,14 +75,14 @@ DESCRIPTIONS = ( connected_state=Sensor.connected_state, ), GardenaBluetoothSensorEntityDescription( - key=Sensor.type.uuid, + key=Sensor.type.unique_id, translation_key="sensor_type", entity_category=EntityCategory.DIAGNOSTIC, char=Sensor.type, connected_state=Sensor.connected_state, ), GardenaBluetoothSensorEntityDescription( - key=Sensor.measurement_timestamp.uuid, + key=Sensor.measurement_timestamp.unique_id, translation_key="sensor_measurement_timestamp", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, @@ -102,9 +102,9 @@ async def async_setup_entry( entities: list[GardenaBluetoothEntity] = [ GardenaBluetoothSensor(coordinator, description, description.context) for description in DESCRIPTIONS - if description.key in coordinator.characteristics + if description.char.unique_id in coordinator.characteristics ] - if Valve.remaining_open_time.uuid in coordinator.characteristics: + if Valve.remaining_open_time.unique_id in coordinator.characteristics: entities.append(GardenaBluetoothRemainSensor(coordinator)) async_add_entities(entities) diff --git a/homeassistant/components/gardena_bluetooth/switch.py b/homeassistant/components/gardena_bluetooth/switch.py index de1fbe22470..053a90aaa4d 100644 --- a/homeassistant/components/gardena_bluetooth/switch.py +++ b/homeassistant/components/gardena_bluetooth/switch.py @@ -35,9 +35,9 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity): """Representation of a valve switch.""" characteristics = { - Valve.state.uuid, - Valve.manual_watering_time.uuid, - Valve.remaining_open_time.uuid, + Valve.state.unique_id, + Valve.manual_watering_time.unique_id, + Valve.remaining_open_time.unique_id, } def __init__( @@ -48,7 +48,7 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity): super().__init__( coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid} ) - self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}" + self._attr_unique_id = f"{coordinator.address}-{Valve.state.unique_id}" self._attr_translation_key = "state" self._attr_is_on = None self._attr_entity_registry_enabled_default = False diff --git a/homeassistant/components/gardena_bluetooth/util.py b/homeassistant/components/gardena_bluetooth/util.py new file mode 100644 index 00000000000..ce2d862c600 --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/util.py @@ -0,0 +1,51 @@ +"""Utility functions for Gardena Bluetooth integration.""" + +import asyncio +from collections.abc import AsyncIterator + +from gardena_bluetooth.parse import ManufacturerData, ProductType + +from homeassistant.components import bluetooth + + +async def _async_service_info( + hass, address +) -> AsyncIterator[bluetooth.BluetoothServiceInfoBleak]: + queue = asyncio.Queue[bluetooth.BluetoothServiceInfoBleak]() + + def _callback( + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + if change != bluetooth.BluetoothChange.ADVERTISEMENT: + return + + queue.put_nowait(service_info) + + service_info = bluetooth.async_last_service_info(hass, address, True) + if service_info: + yield service_info + + cancel = bluetooth.async_register_callback( + hass, + _callback, + {bluetooth.match.ADDRESS: address}, + bluetooth.BluetoothScanningMode.ACTIVE, + ) + try: + while True: + yield await queue.get() + finally: + cancel() + + +async def async_get_product_type(hass, address: str) -> ProductType: + """Wait for enough packets of manufacturer data to get the product type.""" + data = ManufacturerData() + + async for service_info in _async_service_info(hass, address): + data.update(service_info.manufacturer_data.get(ManufacturerData.company, b"")) + product_type = ProductType.from_manufacturer_data(data) + if product_type is not ProductType.UNKNOWN: + return product_type + raise AssertionError("Iterator should have been infinite") diff --git a/homeassistant/components/gardena_bluetooth/valve.py b/homeassistant/components/gardena_bluetooth/valve.py index 247a85f93f1..a5fa2796244 100644 --- a/homeassistant/components/gardena_bluetooth/valve.py +++ b/homeassistant/components/gardena_bluetooth/valve.py @@ -44,9 +44,9 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity): _attr_device_class = ValveDeviceClass.WATER characteristics = { - Valve.state.uuid, - Valve.manual_watering_time.uuid, - Valve.remaining_open_time.uuid, + Valve.state.unique_id, + Valve.manual_watering_time.unique_id, + Valve.remaining_open_time.unique_id, } def __init__( @@ -57,7 +57,7 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity): super().__init__( coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid} ) - self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}" + self._attr_unique_id = f"{coordinator.address}-{Valve.state.unique_id}" def _handle_coordinator_update(self) -> None: self._attr_is_closed = not self.coordinator.get_cached(Valve.state) diff --git a/homeassistant/components/husqvarna_automower_ble/config_flow.py b/homeassistant/components/husqvarna_automower_ble/config_flow.py index c1002a9b0e4..d36b89f2d13 100644 --- a/homeassistant/components/husqvarna_automower_ble/config_flow.py +++ b/homeassistant/components/husqvarna_automower_ble/config_flow.py @@ -58,7 +58,7 @@ def _is_supported(discovery_info: BluetoothServiceInfo): # Some mowers only expose the serial number in the manufacturer data # and not the product type, so we allow None here as well. - if product_type not in (ProductType.MOWER, None): + if product_type not in (ProductType.MOWER, ProductType.UNKNOWN): LOGGER.debug("Unsupported device: %s (%s)", manufacturer_data, discovery_info) return False diff --git a/homeassistant/components/husqvarna_automower_ble/manifest.json b/homeassistant/components/husqvarna_automower_ble/manifest.json index a1ce1e118f4..3c9fb7d57c8 100644 --- a/homeassistant/components/husqvarna_automower_ble/manifest.json +++ b/homeassistant/components/husqvarna_automower_ble/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["automower-ble==0.2.8", "gardena-bluetooth==1.6.0"] + "requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a2a2b5c04f4..929f089c276 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1026,7 +1026,7 @@ gTTS==2.5.3 # homeassistant.components.gardena_bluetooth # homeassistant.components.husqvarna_automower_ble -gardena-bluetooth==1.6.0 +gardena-bluetooth==2.1.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 503118ac85f..3b04fdff51b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -905,7 +905,7 @@ gTTS==2.5.3 # homeassistant.components.gardena_bluetooth # homeassistant.components.husqvarna_automower_ble -gardena-bluetooth==1.6.0 +gardena-bluetooth==2.1.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.14 diff --git a/tests/components/gardena_bluetooth/conftest.py b/tests/components/gardena_bluetooth/conftest.py index 6726525a317..732174157c7 100644 --- a/tests/components/gardena_bluetooth/conftest.py +++ b/tests/components/gardena_bluetooth/conftest.py @@ -8,7 +8,7 @@ from freezegun.api import FrozenDateTimeFactory from gardena_bluetooth.client import Client from gardena_bluetooth.const import DeviceInformation from gardena_bluetooth.exceptions import CharacteristicNotFound -from gardena_bluetooth.parse import Characteristic +from gardena_bluetooth.parse import Characteristic, Service import pytest from homeassistant.components.gardena_bluetooth.const import DOMAIN @@ -83,7 +83,7 @@ def mock_client( ) -> Generator[Mock]: """Auto mock bluetooth.""" - client = Mock(spec_set=Client) + client_class = Mock() SENTINEL = object() @@ -106,19 +106,32 @@ def mock_client( return default return val - def _all_char(): + def _all_char_uuid(): return set(mock_read_char_raw.keys()) + def _all_char(): + product_type = client_class.call_args.args[1] + services = Service.services_for_product_type(product_type) + return { + char.unique_id: char + for service in services + for char in service.characteristics.values() + if char.uuid in mock_read_char_raw + } + + client = Mock(spec_set=Client) client.read_char.side_effect = _read_char client.read_char_raw.side_effect = _read_char_raw - client.get_all_characteristics_uuid.side_effect = _all_char + client.get_all_characteristics_uuid.side_effect = _all_char_uuid + client.get_all_characteristics.side_effect = _all_char + client_class.return_value = client with ( patch( "homeassistant.components.gardena_bluetooth.config_flow.Client", - return_value=client, + new=client_class, ), - patch("homeassistant.components.gardena_bluetooth.Client", return_value=client), + patch("homeassistant.components.gardena_bluetooth.Client", new=client_class), ): yield client diff --git a/tests/components/gardena_bluetooth/test_init.py b/tests/components/gardena_bluetooth/test_init.py index 53688846c07..cf7ca36b2db 100644 --- a/tests/components/gardena_bluetooth/test_init.py +++ b/tests/components/gardena_bluetooth/test_init.py @@ -1,21 +1,26 @@ """Test the Gardena Bluetooth setup.""" +import asyncio from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import Mock, patch from gardena_bluetooth.const import Battery from syrupy.assertion import SnapshotAssertion from homeassistant.components.gardena_bluetooth import DeviceUnavailable from homeassistant.components.gardena_bluetooth.const import DOMAIN +from homeassistant.components.gardena_bluetooth.util import ( + async_get_product_type as original_get_product_type, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.util import utcnow -from . import WATER_TIMER_SERVICE_INFO +from . import MISSING_MANUFACTURER_DATA_SERVICE_INFO, WATER_TIMER_SERVICE_INFO from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.bluetooth import inject_bluetooth_service_info async def test_setup( @@ -28,12 +33,10 @@ async def test_setup( """Test setup creates expected devices.""" mock_read_char_raw[Battery.battery_level.uuid] = Battery.battery_level.encode(100) + inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO) mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - - assert mock_entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_setup(mock_entry.entry_id) is True device = device_registry.async_get_device( identifiers={(DOMAIN, WATER_TIMER_SERVICE_INFO.address)} @@ -41,11 +44,49 @@ async def test_setup( assert device == snapshot +async def test_setup_delayed_product( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_entry: MockConfigEntry, + mock_read_char_raw: dict[str, bytes], + snapshot: SnapshotAssertion, +) -> None: + """Test setup creates expected devices.""" + + mock_read_char_raw[Battery.battery_level.uuid] = Battery.battery_level.encode(100) + + mock_entry.add_to_hass(hass) + + event = asyncio.Event() + + async def _get_product_type(*args, **kwargs): + event.set() + return await original_get_product_type(*args, **kwargs) + + with patch( + "homeassistant.components.gardena_bluetooth.async_get_product_type", + wraps=_get_product_type, + ): + async with asyncio.TaskGroup() as tg: + setup_task = tg.create_task( + hass.config_entries.async_setup(mock_entry.entry_id) + ) + + await event.wait() + assert mock_entry.state is ConfigEntryState.SETUP_IN_PROGRESS + inject_bluetooth_service_info(hass, MISSING_MANUFACTURER_DATA_SERVICE_INFO) + inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO) + + assert await setup_task is True + + async def test_setup_retry( hass: HomeAssistant, mock_entry: MockConfigEntry, mock_client: Mock ) -> None: """Test setup creates expected devices.""" + inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO) + original_read_char = mock_client.read_char.side_effect mock_client.read_char.side_effect = DeviceUnavailable mock_entry.add_to_hass(hass)