diff --git a/homeassistant/components/husqvarna_automower_ble/config_flow.py b/homeassistant/components/husqvarna_automower_ble/config_flow.py index c8f1cfaf6302..d6ec59f0ec96 100644 --- a/homeassistant/components/husqvarna_automower_ble/config_flow.py +++ b/homeassistant/components/husqvarna_automower_ble/config_flow.py @@ -10,6 +10,8 @@ from automower_ble.mower import Mower from automower_ble.protocol import ResponseResult from bleak import BleakError from bleak_retry_connector import get_device +from gardena_bluetooth.const import ScanService +from gardena_bluetooth.parse import ManufacturerData, ProductType import voluptuous as vol from homeassistant.components import bluetooth @@ -22,20 +24,31 @@ from .const import DOMAIN, LOGGER def _is_supported(discovery_info: BluetoothServiceInfo): """Check if device is supported.""" + if ScanService not in discovery_info.service_uuids: + LOGGER.debug( + "Unsupported device, missing service %s: %s", ScanService, discovery_info + ) + return False - LOGGER.debug( - "%s manufacturer data: %s", - discovery_info.address, - discovery_info.manufacturer_data, - ) + if not (data := discovery_info.manufacturer_data.get(ManufacturerData.company)): + LOGGER.debug( + "Unsupported device, missing manufacturer data %s: %s", + ManufacturerData.company, + discovery_info, + ) + return False - manufacturer = any(key == 1062 for key in discovery_info.manufacturer_data) - service_husqvarna = any( - service == "98bd0001-0b0e-421a-84e5-ddbf75dc6de4" - for service in discovery_info.service_uuids - ) + manufacturer_data = ManufacturerData.decode(data) + product_type = ProductType.from_manufacturer_data(manufacturer_data) - return manufacturer and service_husqvarna + # 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): + LOGGER.debug("Unsupported device: %s (%s)", manufacturer_data, discovery_info) + return False + + LOGGER.debug("Supported device: %s", manufacturer_data) + return True def _pin_valid(pin: str) -> bool: diff --git a/homeassistant/components/husqvarna_automower_ble/manifest.json b/homeassistant/components/husqvarna_automower_ble/manifest.json index 50430c2a9fad..68cfd5e84860 100644 --- a/homeassistant/components/husqvarna_automower_ble/manifest.json +++ b/homeassistant/components/husqvarna_automower_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble", "iot_class": "local_polling", - "requirements": ["automower-ble==0.2.7"] + "requirements": ["automower-ble==0.2.7", "gardena-bluetooth==1.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7ab6a1c73873..2b06cec2250a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -992,6 +992,7 @@ fyta_cli==0.7.2 gTTS==2.5.3 # homeassistant.components.gardena_bluetooth +# homeassistant.components.husqvarna_automower_ble gardena-bluetooth==1.6.0 # homeassistant.components.google_assistant_sdk diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 68d4ab6f690c..e4273d3a0e8c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -862,6 +862,7 @@ fyta_cli==0.7.2 gTTS==2.5.3 # homeassistant.components.gardena_bluetooth +# homeassistant.components.husqvarna_automower_ble gardena-bluetooth==1.6.0 # homeassistant.components.google_assistant_sdk diff --git a/tests/components/husqvarna_automower_ble/__init__.py b/tests/components/husqvarna_automower_ble/__init__.py index 841b6f65516a..fbb2a67ab9ad 100644 --- a/tests/components/husqvarna_automower_ble/__init__.py +++ b/tests/components/husqvarna_automower_ble/__init__.py @@ -9,15 +9,23 @@ from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info -AUTOMOWER_SERVICE_INFO = BluetoothServiceInfo( +AUTOMOWER_SERVICE_INFO_SERIAL = BluetoothServiceInfo( name="305", address="00000000-0000-0000-0000-000000000003", rssi=-63, service_data={}, manufacturer_data={1062: b"\x05\x04\xbf\xcf\xbb\r"}, - service_uuids=[ - "98bd0001-0b0e-421a-84e5-ddbf75dc6de4", - ], + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], + source="local", +) + +AUTOMOWER_SERVICE_INFO_MOWER = BluetoothServiceInfo( + name="305", + address="00000000-0000-0000-0000-000000000003", + rssi=-63, + service_data={}, + manufacturer_data={1062: bytes.fromhex("02050104060a2301")}, + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], source="local", ) @@ -27,9 +35,7 @@ AUTOMOWER_UNNAMED_SERVICE_INFO = BluetoothServiceInfo( rssi=-63, service_data={}, manufacturer_data={1062: b"\x05\x04\xbf\xcf\xbb\r"}, - service_uuids=[ - "98bd0001-0b0e-421a-84e5-ddbf75dc6de4", - ], + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], source="local", ) @@ -39,9 +45,7 @@ AUTOMOWER_MISSING_MANUFACTURER_DATA_SERVICE_INFO = BluetoothServiceInfo( rssi=-63, service_data={}, manufacturer_data={}, - service_uuids=[ - "98bd0001-0b0e-421a-84e5-ddbf75dc6de4", - ], + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], source="local", ) @@ -51,9 +55,30 @@ AUTOMOWER_UNSUPPORTED_GROUP_SERVICE_INFO = BluetoothServiceInfo( rssi=-63, service_data={}, manufacturer_data={1062: b"\x05\x04\xbf\xcf\xbb\r"}, - service_uuids=[ - "98bd0001-0b0e-421a-84e5-ddbf75dc6de4", - ], + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], + source="local", +) + +MISSING_SERVICE_SERVICE_INFO = BluetoothServiceInfo( + name="Blah", + address="00000000-0000-0000-0000-000000000001", + rssi=-63, + service_data={}, + manufacturer_data={}, + service_uuids=[], + source="local", +) + + +WATER_TIMER_SERVICE_INFO = BluetoothServiceInfo( + name="Timer", + address="00000000-0000-0000-0000-000000000001", + rssi=-63, + service_data={}, + manufacturer_data={ + 1062: b"\x02\x07d\x02\x05\x01\x02\x08\x00\x02\t\x01\x04\x06\x12\x00\x01" + }, + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], source="local", ) @@ -63,7 +88,7 @@ async def setup_entry( ) -> None: """Make sure the device is available.""" - inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO) + inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO_SERIAL) with patch("homeassistant.components.husqvarna_automower_ble.PLATFORMS", platforms): mock_entry.add_to_hass(hass) diff --git a/tests/components/husqvarna_automower_ble/conftest.py b/tests/components/husqvarna_automower_ble/conftest.py index 820edb29059a..f5aebf54b7aa 100644 --- a/tests/components/husqvarna_automower_ble/conftest.py +++ b/tests/components/husqvarna_automower_ble/conftest.py @@ -9,7 +9,7 @@ import pytest from homeassistant.components.husqvarna_automower_ble.const import DOMAIN from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN -from . import AUTOMOWER_SERVICE_INFO +from . import AUTOMOWER_SERVICE_INFO_SERIAL from tests.common import MockConfigEntry @@ -56,9 +56,9 @@ def mock_config_entry() -> MockConfigEntry: domain=DOMAIN, title="Husqvarna AutoMower", data={ - CONF_ADDRESS: AUTOMOWER_SERVICE_INFO.address, + CONF_ADDRESS: AUTOMOWER_SERVICE_INFO_SERIAL.address, CONF_CLIENT_ID: 1197489078, CONF_PIN: "1234", }, - unique_id=AUTOMOWER_SERVICE_INFO.address, + unique_id=AUTOMOWER_SERVICE_INFO_SERIAL.address, ) diff --git a/tests/components/husqvarna_automower_ble/test_config_flow.py b/tests/components/husqvarna_automower_ble/test_config_flow.py index 7b47063975e1..affa3715ab82 100644 --- a/tests/components/husqvarna_automower_ble/test_config_flow.py +++ b/tests/components/husqvarna_automower_ble/test_config_flow.py @@ -11,11 +11,15 @@ from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from . import ( AUTOMOWER_MISSING_MANUFACTURER_DATA_SERVICE_INFO, - AUTOMOWER_SERVICE_INFO, + AUTOMOWER_SERVICE_INFO_MOWER, + AUTOMOWER_SERVICE_INFO_SERIAL, AUTOMOWER_UNNAMED_SERVICE_INFO, + MISSING_SERVICE_SERVICE_INFO, + WATER_TIMER_SERVICE_INFO, ) from tests.common import MockConfigEntry @@ -121,10 +125,16 @@ async def test_user_selection_incorrect_pin( } -async def test_bluetooth(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "service_info", + [AUTOMOWER_SERVICE_INFO_MOWER, AUTOMOWER_SERVICE_INFO_SERIAL], +) +async def test_bluetooth( + hass: HomeAssistant, service_info: BluetoothServiceInfo +) -> None: """Test bluetooth device discovery.""" - inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO) + inject_bluetooth_service_info(hass, service_info) await hass.async_block_till_done(wait_background_tasks=True) result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0] @@ -157,7 +167,7 @@ async def test_bluetooth_incorrect_pin( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_BLUETOOTH}, - data=AUTOMOWER_SERVICE_INFO, + data=AUTOMOWER_SERVICE_INFO_SERIAL, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" @@ -214,7 +224,7 @@ async def test_bluetooth_unknown_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_BLUETOOTH}, - data=AUTOMOWER_SERVICE_INFO, + data=AUTOMOWER_SERVICE_INFO_SERIAL, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" @@ -241,7 +251,7 @@ async def test_bluetooth_not_paired( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_BLUETOOTH}, - data=AUTOMOWER_SERVICE_INFO, + data=AUTOMOWER_SERVICE_INFO_SERIAL, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" @@ -274,18 +284,26 @@ async def test_bluetooth_not_paired( } -async def test_bluetooth_invalid(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "service_info", + [ + AUTOMOWER_MISSING_MANUFACTURER_DATA_SERVICE_INFO, + MISSING_SERVICE_SERVICE_INFO, + WATER_TIMER_SERVICE_INFO, + ], +) +async def test_bluetooth_invalid( + hass: HomeAssistant, service_info: BluetoothServiceInfo +) -> None: """Test bluetooth device discovery with invalid data.""" - inject_bluetooth_service_info( - hass, AUTOMOWER_MISSING_MANUFACTURER_DATA_SERVICE_INFO - ) + inject_bluetooth_service_info(hass, service_info) await hass.async_block_till_done(wait_background_tasks=True) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_BLUETOOTH}, - data=AUTOMOWER_MISSING_MANUFACTURER_DATA_SERVICE_INFO, + data=service_info, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_devices_found" diff --git a/tests/components/husqvarna_automower_ble/test_init.py b/tests/components/husqvarna_automower_ble/test_init.py index 341cc3c282fe..f10ae1fa7430 100644 --- a/tests/components/husqvarna_automower_ble/test_init.py +++ b/tests/components/husqvarna_automower_ble/test_init.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from . import AUTOMOWER_SERVICE_INFO +from . import AUTOMOWER_SERVICE_INFO_SERIAL from tests.common import MockConfigEntry @@ -34,7 +34,7 @@ async def test_setup( assert mock_config_entry.state is ConfigEntryState.LOADED device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, f"{AUTOMOWER_SERVICE_INFO.address}_1197489078")} + identifiers={(DOMAIN, f"{AUTOMOWER_SERVICE_INFO_SERIAL.address}_1197489078")} ) assert device_entry == snapshot