From 1923c2e88d94d4fee24e621dcd5458b32df5ca22 Mon Sep 17 00:00:00 2001 From: Federico Zivolo <5382443+FezVrasta@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:53:26 +0200 Subject: [PATCH] Fix ISEO Argo BLE offering unrelated devices for discovery (#182315) Co-authored-by: Claude Opus 5 (1M context) --- .../components/iseo_argo_ble/config_flow.py | 29 +++- tests/components/iseo_argo_ble/__init__.py | 11 +- .../iseo_argo_ble/snapshots/test_lock.ambr | 2 +- .../iseo_argo_ble/test_config_flow.py | 127 +++++++++--------- 4 files changed, 100 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/iseo_argo_ble/config_flow.py b/homeassistant/components/iseo_argo_ble/config_flow.py index 94d293ee48e4..be2070dfca97 100644 --- a/homeassistant/components/iseo_argo_ble/config_flow.py +++ b/homeassistant/components/iseo_argo_ble/config_flow.py @@ -33,12 +33,27 @@ from .const import CONF_PRIV_SCALAR, DEFAULT_USER_SUBTYPE, DOMAIN _LOGGER = logging.getLogger(__name__) +# OUI assigned to Iseo Serrature s.p.a.; locks advertise a public address. +_ISEO_OUI = "00:15:42" + def _generate_identity() -> ec.EllipticCurvePrivateKey: """Generate a fresh SECP224R1 private key for use as an Argo BT identity.""" return ec.generate_private_key(ec.SECP224R1()) +def _is_iseo_lock(info: BluetoothServiceInfoBleak) -> bool: + """Return True when the advertisement really comes from an ISEO lock. + + The 0xF000-0xF03F device-type UUID the discovery matcher keys on sits in a + range the Bluetooth SIG leaves unassigned and other vendors reuse freely, so + the address has to carry ISEO's OUI as well. + """ + return info.address.lower().startswith(_ISEO_OUI) and is_iseo_advertisement( + list(info.service_uuids or []) + ) + + def _discover_locks(hass: HomeAssistant) -> list[BluetoothServiceInfoBleak]: """Query HA's bluetooth integration for nearby ISEO locks.""" all_devices = sorted( @@ -52,7 +67,7 @@ def _discover_locks(hass: HomeAssistant) -> list[BluetoothServiceInfoBleak]: found: list[BluetoothServiceInfoBleak] = [] for info in all_devices: - if not is_iseo_advertisement(list(info.service_uuids or [])): + if not _is_iseo_lock(info): continue _LOGGER.debug( " %s name=%r rssi=%d — ISEO lock", @@ -145,12 +160,18 @@ class IseoConfigFlow(ConfigFlow, domain=DOMAIN): self, discovery_info: BluetoothServiceInfoBleak ) -> ConfigFlowResult: """Called by HA when a matching BLE advertisement is seen.""" + if not _is_iseo_lock(discovery_info): + _LOGGER.debug( + "Ignoring %s (%s): not an ISEO lock, service UUIDs %s", + discovery_info.name, + discovery_info.address, + list(discovery_info.service_uuids or []), + ) + return self.async_abort(reason="not_iseo_device") + await self.async_set_unique_id(format_mac(discovery_info.address)) self._abort_if_unique_id_configured() - if not is_iseo_advertisement(list(discovery_info.service_uuids or [])): - return self.async_abort(reason="not_iseo_device") - priv = await self.hass.async_add_executor_job(_generate_identity) priv_int = priv.private_numbers().private_value new_uuid = uuid_module.uuid4().bytes diff --git a/tests/components/iseo_argo_ble/__init__.py b/tests/components/iseo_argo_ble/__init__.py index 64dc7464ff7d..f71c6704574e 100644 --- a/tests/components/iseo_argo_ble/__init__.py +++ b/tests/components/iseo_argo_ble/__init__.py @@ -7,10 +7,17 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -MOCK_ADDRESS = "AA:BB:CC:DD:EE:FF" +MOCK_ADDRESS = "00:15:42:AA:BB:CC" # 00:15:42 is ISEO's OUI MOCK_UUID_HEX = "eaa06132486f426cb0d26c6b9b578add" MOCK_PRIV_SCALAR = "0x" + "a" * 56 # 224-bit hex scalar +# A lock advertises a 0xF0xx device-type UUID; the rest of the list carries +# protocol info and, when the feature is enabled, its system state. +ISEO_SERVICE_UUIDS = [ + "0000f001-0000-1000-8000-00805f9b34fb", + "0000d004-0000-1000-8000-00805f9b34fb", +] + # A fake BluetoothServiceInfoBleak for testing MOCK_SERVICE_INFO = BluetoothServiceInfoBleak( name="ISEO Lock", @@ -18,7 +25,7 @@ MOCK_SERVICE_INFO = BluetoothServiceInfoBleak( rssi=-60, manufacturer_data={}, service_data={}, - service_uuids=["0000f000-0000-1000-8000-00805f9b34fb"], + service_uuids=ISEO_SERVICE_UUIDS, source="local", device=MagicMock(), advertisement=MagicMock(), diff --git a/tests/components/iseo_argo_ble/snapshots/test_lock.ambr b/tests/components/iseo_argo_ble/snapshots/test_lock.ambr index 598bc7ef100d..b1cfc4ecbe16 100644 --- a/tests/components/iseo_argo_ble/snapshots/test_lock.ambr +++ b/tests/components/iseo_argo_ble/snapshots/test_lock.ambr @@ -32,7 +32,7 @@ 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, - 'unique_id': 'aa:bb:cc:dd:ee:ff', + 'unique_id': '00:15:42:aa:bb:cc', 'unit_of_measurement': None, }) # --- diff --git a/tests/components/iseo_argo_ble/test_config_flow.py b/tests/components/iseo_argo_ble/test_config_flow.py index a6c3b2f0df1a..e970a25f9bf0 100644 --- a/tests/components/iseo_argo_ble/test_config_flow.py +++ b/tests/components/iseo_argo_ble/test_config_flow.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac -from . import MOCK_ADDRESS, MOCK_SERVICE_INFO, MOCK_UUID_HEX +from . import ISEO_SERVICE_UUIDS, MOCK_ADDRESS, MOCK_SERVICE_INFO, MOCK_UUID_HEX from tests.common import MockConfigEntry @@ -52,15 +52,11 @@ async def test_bluetooth_discovery_confirm_and_register( mock_iseo_client: MagicMock, ) -> None: """Test full bluetooth discovery → confirm → gw_register flow.""" - with patch( - "homeassistant.components.iseo_argo_ble.config_flow.is_iseo_advertisement", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_BLUETOOTH}, - data=MOCK_SERVICE_INFO, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=MOCK_SERVICE_INFO, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" @@ -146,15 +142,42 @@ async def test_bluetooth_discovery_abort_if_already_configured( assert result["reason"] == "already_configured" -async def test_bluetooth_discovery_not_iseo(hass: HomeAssistant) -> None: - """Test bluetooth discovery aborts for non-ISEO devices.""" +@pytest.mark.parametrize( + ("address", "service_uuids"), + [ + pytest.param( + MOCK_ADDRESS, + ["0000180f-0000-1000-8000-00805f9b34fb"], + id="iseo_address_without_device_type_uuid", + ), + pytest.param( + "F8:24:41:C5:98:2C", + ISEO_SERVICE_UUIDS, + id="device_type_uuid_without_iseo_address", + ), + pytest.param( + "F8:24:41:C5:98:2C", + [], + id="neither", + ), + ], +) +async def test_bluetooth_discovery_not_iseo( + hass: HomeAssistant, address: str, service_uuids: list[str] +) -> None: + """Test bluetooth discovery aborts for non-ISEO devices. + + The 0xF000-0xF03F device-type range is unassigned by the Bluetooth SIG, so + unrelated vendors advertise in it too; matching it must not be enough on its + own to offer a discovery. + """ non_iseo_info = BluetoothServiceInfoBleak( name="SomeOtherDevice", - address="11:22:33:44:55:66", + address=address, rssi=-70, manufacturer_data={}, service_data={}, - service_uuids=["0000180f-0000-1000-8000-00805f9b34fb"], # not ISEO + service_uuids=service_uuids, source="local", device=MagicMock(), advertisement=MagicMock(), @@ -163,15 +186,11 @@ async def test_bluetooth_discovery_not_iseo(hass: HomeAssistant) -> None: tx_power=None, ) - with patch( - "homeassistant.components.iseo_argo_ble.config_flow.is_iseo_advertisement", - return_value=False, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_BLUETOOTH}, - data=non_iseo_info, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=non_iseo_info, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_iseo_device" @@ -199,15 +218,11 @@ async def test_gw_register_connection_error( """Test gw_register handles connection error.""" mock_iseo_client.setup_gateway.side_effect = IseoConnectionError - with patch( - "homeassistant.components.iseo_argo_ble.config_flow.is_iseo_advertisement", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_BLUETOOTH}, - data=MOCK_SERVICE_INFO, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=MOCK_SERVICE_INFO, + ) # Move to gw_register result2 = await hass.config_entries.flow.async_configure( @@ -231,15 +246,11 @@ async def test_gw_register_auth_error( """Test gw_register handles auth error.""" mock_iseo_client.setup_gateway.side_effect = IseoAuthError - with patch( - "homeassistant.components.iseo_argo_ble.config_flow.is_iseo_advertisement", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_BLUETOOTH}, - data=MOCK_SERVICE_INFO, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=MOCK_SERVICE_INFO, + ) # Move to gw_register result2 = await hass.config_entries.flow.async_configure( @@ -260,15 +271,11 @@ async def test_gw_register_no_ble_device( hass: HomeAssistant, ) -> None: """Test gw_register handles case where ble_device is None.""" - with patch( - "homeassistant.components.iseo_argo_ble.config_flow.is_iseo_advertisement", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_BLUETOOTH}, - data=MOCK_SERVICE_INFO, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=MOCK_SERVICE_INFO, + ) # Move to gw_register result2 = await hass.config_entries.flow.async_configure( @@ -296,15 +303,11 @@ async def test_gw_register_unknown_error( """Test gw_register handles unknown error.""" mock_iseo_client.setup_gateway.side_effect = Exception("BOOM") - with patch( - "homeassistant.components.iseo_argo_ble.config_flow.is_iseo_advertisement", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_BLUETOOTH}, - data=MOCK_SERVICE_INFO, - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=MOCK_SERVICE_INFO, + ) # Move to gw_register result2 = await hass.config_entries.flow.async_configure( @@ -321,14 +324,14 @@ async def test_gw_register_unknown_error( async def test_discover_locks(hass: HomeAssistant) -> None: - """Test the _discover_locks helper function.""" + """Test _discover_locks skips devices that only look like a lock.""" non_iseo_info = BluetoothServiceInfoBleak( name="Other", - address="11:22:33:44:55:66", + address="F8:24:41:C5:98:2C", rssi=-70, manufacturer_data={}, service_data={}, - service_uuids=[], + service_uuids=ISEO_SERVICE_UUIDS, source="local", device=MagicMock(), advertisement=MagicMock(),