mirror of
https://github.com/home-assistant/core.git
synced 2026-09-26 09:23:17 -04:00
Fix ISEO Argo BLE offering unrelated devices for discovery (#182315)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
176dacad95
commit
1923c2e88d
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user