Skip to content

Commit f51f700

Browse files
authored
fix: handle case where two scanners have the exact same rssi (#295)
1 parent 01807ea commit f51f700

File tree

3 files changed

+258
-5
lines changed

3 files changed

+258
-5
lines changed

src/habluetooth/base_scanner.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,15 +194,19 @@ def _score_connection_paths(
194194
scanner_connections_in_progress = len(self._connect_in_progress)
195195
previous_failures = self._connect_failures.get(address, 0)
196196

197+
# Use a minimum rssi_diff of 1 to ensure penalties are meaningful
198+
# even when scanners have identical RSSI
199+
effective_rssi_diff = max(rssi_diff, 1)
200+
197201
# Penalize scanners with connections in progress
198202
if scanner_connections_in_progress:
199203
# Very large penalty for multiple connections in progress
200204
# to avoid overloading the adapter
201-
score -= rssi_diff * scanner_connections_in_progress * 1.01
205+
score -= effective_rssi_diff * scanner_connections_in_progress * 1.01
202206

203207
# Penalize based on previous failures
204208
if previous_failures:
205-
score -= rssi_diff * previous_failures * 0.51
209+
score -= effective_rssi_diff * previous_failures * 0.51
206210

207211
# Consider connection slot availability
208212
allocation = self.get_allocations()
@@ -212,7 +216,7 @@ def _score_connection_paths(
212216
return NO_RSSI_VALUE
213217
if allocation.free == 1:
214218
# Last slot available - small penalty to prefer adapters with more slots
215-
score -= rssi_diff * 0.76
219+
score -= effective_rssi_diff * 0.76
216220

217221
return score
218222

src/habluetooth/wrappers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,7 @@ def _async_get_best_available_backend_and_device(
442442
key=lambda x: x.advertisement.rssi,
443443
reverse=True,
444444
)
445+
rssi_diff = 0 # Default when there's only one device
445446
if len(sorted_devices) > 1:
446447
rssi_diff = (
447448
sorted_devices[0].advertisement.rssi
@@ -470,7 +471,7 @@ def _async_get_best_available_backend_and_device(
470471
if (allocations := device.scanner.get_allocations())
471472
else ""
472473
)
473-
+ f"(score={device.score_connection_path(0)})"
474+
+ f"(score={device.score_connection_path(rssi_diff)})"
474475
)
475476
for device in sorted_devices
476477
),

tests/test_wrappers.py

Lines changed: 249 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import asyncio
66
import logging
77
from collections.abc import Generator
8-
from contextlib import contextmanager
8+
from contextlib import contextmanager, suppress
99
from typing import Any
1010
from unittest.mock import Mock, patch
1111

@@ -14,6 +14,7 @@
1414
from bleak.backends.device import BLEDevice
1515
from bleak.backends.scanner import AdvertisementData
1616
from bleak.exc import BleakError
17+
from bleak_retry_connector import Allocations
1718
from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME
1819

1920
from habluetooth import BaseHaRemoteScanner, HaBluetoothConnector
@@ -1465,3 +1466,250 @@ def is_connected(self) -> bool:
14651466
assert mock_mgmt_ctl.load_conn_params.call_count == 0
14661467

14671468
cancel_remote()
1469+
1470+
1471+
@pytest.mark.asyncio
1472+
async def test_connection_path_scoring_with_slots_and_logging(
1473+
caplog: pytest.LogCaptureFixture,
1474+
) -> None:
1475+
"""Test connection path scoring and logging reflects slot availability."""
1476+
from bleak_retry_connector import Allocations
1477+
1478+
manager = _get_manager()
1479+
1480+
class FakeBleakClientNoConnect(BaseFakeBleakClient):
1481+
"""Fake bleak client that doesn't connect."""
1482+
1483+
async def connect(self, *args, **kwargs):
1484+
"""Don't actually connect."""
1485+
raise BleakError("Test - connection not needed")
1486+
1487+
# Create fake connectors
1488+
fake_connector_1 = HaBluetoothConnector(
1489+
client=FakeBleakClientNoConnect, source="scanner1", can_connect=lambda: True
1490+
)
1491+
fake_connector_2 = HaBluetoothConnector(
1492+
client=FakeBleakClientNoConnect, source="scanner2", can_connect=lambda: True
1493+
)
1494+
fake_connector_3 = HaBluetoothConnector(
1495+
client=FakeBleakClientNoConnect, source="scanner3", can_connect=lambda: True
1496+
)
1497+
1498+
# Create scanners with different sources
1499+
scanner1 = FakeScanner("scanner1", "Scanner 1", fake_connector_1, True)
1500+
scanner2 = FakeScanner("scanner2", "Scanner 2", fake_connector_2, True)
1501+
scanner3 = FakeScanner("scanner3", "Scanner 3", fake_connector_3, True)
1502+
1503+
# Mock get_allocations for each scanner using patch.object
1504+
with (
1505+
patch.object(
1506+
scanner1,
1507+
"get_allocations",
1508+
return_value=Allocations(
1509+
adapter="scanner1",
1510+
slots=3,
1511+
free=1, # Only 1 slot free - should get penalty
1512+
allocated=["AA:BB:CC:DD:EE:01", "AA:BB:CC:DD:EE:02"],
1513+
),
1514+
),
1515+
patch.object(
1516+
scanner2,
1517+
"get_allocations",
1518+
return_value=Allocations(
1519+
adapter="scanner2",
1520+
slots=3,
1521+
free=2, # 2 slots free - no penalty
1522+
allocated=["AA:BB:CC:DD:EE:03"],
1523+
),
1524+
),
1525+
patch.object(
1526+
scanner3,
1527+
"get_allocations",
1528+
return_value=Allocations(
1529+
adapter="scanner3",
1530+
slots=3,
1531+
free=3, # All slots free - no penalty
1532+
allocated=[],
1533+
),
1534+
),
1535+
):
1536+
cancel1 = manager.async_register_scanner(scanner1)
1537+
cancel2 = manager.async_register_scanner(scanner2)
1538+
cancel3 = manager.async_register_scanner(scanner3)
1539+
1540+
# Inject advertisements with different RSSI values
1541+
device1 = generate_ble_device(
1542+
"00:00:00:00:00:01", "Test Device", {"source": "scanner1"}
1543+
)
1544+
adv_data1 = generate_advertisement_data(local_name="Test Device", rssi=-60)
1545+
scanner1.inject_advertisement(device1, adv_data1)
1546+
1547+
device2 = generate_ble_device(
1548+
"00:00:00:00:00:01", "Test Device", {"source": "scanner2"}
1549+
)
1550+
adv_data2 = generate_advertisement_data(local_name="Test Device", rssi=-65)
1551+
scanner2.inject_advertisement(device2, adv_data2)
1552+
1553+
device3 = generate_ble_device(
1554+
"00:00:00:00:00:01", "Test Device", {"source": "scanner3"}
1555+
)
1556+
adv_data3 = generate_advertisement_data(local_name="Test Device", rssi=-70)
1557+
scanner3.inject_advertisement(device3, adv_data3)
1558+
1559+
await asyncio.sleep(0)
1560+
1561+
# Try to connect with logging enabled
1562+
with caplog.at_level(logging.INFO):
1563+
client = bleak.BleakClient("00:00:00:00:00:01")
1564+
with suppress(BleakError):
1565+
await client.connect()
1566+
1567+
# Check that the log contains the connection paths with correct scoring
1568+
log_text = caplog.text
1569+
assert "Found 3 connection path(s)" in log_text
1570+
1571+
# Extract the log line with connection paths
1572+
for line in caplog.text.splitlines():
1573+
if "Found 3 connection path(s)" in line:
1574+
# rssi_diff = best_rssi - second_best_rssi = -60 - (-65) = 5
1575+
# Scanner 1 has best RSSI (-60) but only 1 slot free, so with penalty:
1576+
# score = -60 - (5 * 0.76) = -63.8
1577+
assert "Scanner 1" in line
1578+
assert "(slots=1/3 free)" in line
1579+
assert "(score=-63.8)" in line
1580+
1581+
# Scanner 2 has RSSI -65 with 2 slots free, no penalty:
1582+
# score = -65
1583+
assert "Scanner 2" in line
1584+
assert "(slots=2/3 free)" in line
1585+
# Check for both -65 and -65.0
1586+
assert ("(score=-65)" in line) or ("(score=-65.0)" in line)
1587+
1588+
# Scanner 3 has RSSI -70 with all slots free, no penalty:
1589+
# score = -70
1590+
assert "Scanner 3" in line
1591+
assert "(slots=3/3 free)" in line
1592+
# Check for both -70 and -70.0
1593+
assert ("(score=-70)" in line) or ("(score=-70.0)" in line)
1594+
1595+
# Verify order: Scanner 1 should be first (best score -63.8),
1596+
# then Scanner 2 (-65), then Scanner 3 (-70)
1597+
scanner1_pos = line.find("Scanner 1")
1598+
scanner2_pos = line.find("Scanner 2")
1599+
scanner3_pos = line.find("Scanner 3")
1600+
1601+
assert scanner1_pos < scanner2_pos < scanner3_pos, (
1602+
f"Expected Scanner 1 before Scanner 2 before Scanner 3, "
1603+
f"but got positions {scanner1_pos}, {scanner2_pos}, {scanner3_pos}"
1604+
)
1605+
break
1606+
else:
1607+
pytest.fail("Could not find connection path log line")
1608+
1609+
cancel1()
1610+
cancel2()
1611+
cancel3()
1612+
1613+
1614+
@pytest.mark.asyncio
1615+
async def test_connection_path_scoring_no_slots_available(
1616+
caplog: pytest.LogCaptureFixture,
1617+
) -> None:
1618+
"""Test that scanners with no free slots are excluded."""
1619+
manager = _get_manager()
1620+
1621+
class FakeBleakClientNoConnect(BaseFakeBleakClient):
1622+
"""Fake bleak client that doesn't connect."""
1623+
1624+
async def connect(self, *args, **kwargs):
1625+
"""Don't actually connect."""
1626+
raise BleakError("Test - connection not needed")
1627+
1628+
# Create fake connectors
1629+
fake_connector_1 = HaBluetoothConnector(
1630+
client=FakeBleakClientNoConnect, source="scanner1", can_connect=lambda: True
1631+
)
1632+
fake_connector_2 = HaBluetoothConnector(
1633+
client=FakeBleakClientNoConnect, source="scanner2", can_connect=lambda: True
1634+
)
1635+
1636+
# Create scanners
1637+
scanner1 = FakeScanner("scanner1", "Scanner 1", fake_connector_1, True)
1638+
scanner2 = FakeScanner("scanner2", "Scanner 2", fake_connector_2, True)
1639+
1640+
# Mock get_allocations - scanner1 has no free slots
1641+
with (
1642+
patch.object(
1643+
scanner1,
1644+
"get_allocations",
1645+
return_value=Allocations(
1646+
adapter="scanner1",
1647+
slots=3,
1648+
free=0, # No slots available - should be excluded
1649+
allocated=[
1650+
"AA:BB:CC:DD:EE:01",
1651+
"AA:BB:CC:DD:EE:02",
1652+
"AA:BB:CC:DD:EE:03",
1653+
],
1654+
),
1655+
),
1656+
patch.object(
1657+
scanner2,
1658+
"get_allocations",
1659+
return_value=Allocations(
1660+
adapter="scanner2", slots=3, free=3, allocated=[] # All slots free
1661+
),
1662+
),
1663+
):
1664+
cancel1 = manager.async_register_scanner(scanner1)
1665+
cancel2 = manager.async_register_scanner(scanner2)
1666+
1667+
# Inject advertisements
1668+
device1 = generate_ble_device(
1669+
"00:00:00:00:00:02", "Test Device", {"source": "scanner1"}
1670+
)
1671+
adv_data1 = generate_advertisement_data(
1672+
local_name="Test Device", rssi=-50
1673+
) # Better RSSI
1674+
scanner1.inject_advertisement(device1, adv_data1)
1675+
1676+
device2 = generate_ble_device(
1677+
"00:00:00:00:00:02", "Test Device", {"source": "scanner2"}
1678+
)
1679+
adv_data2 = generate_advertisement_data(
1680+
local_name="Test Device", rssi=-70
1681+
) # Worse RSSI
1682+
scanner2.inject_advertisement(device2, adv_data2)
1683+
1684+
await asyncio.sleep(0)
1685+
1686+
# Try to connect with logging enabled
1687+
with caplog.at_level(logging.INFO):
1688+
client = bleak.BleakClient("00:00:00:00:00:02")
1689+
with suppress(BleakError):
1690+
await client.connect()
1691+
1692+
# Check that only scanner2 is in the connection paths
1693+
log_text = caplog.text
1694+
assert (
1695+
"Found 1 connection path(s)" in log_text
1696+
or "Found 2 connection path(s)" in log_text
1697+
)
1698+
1699+
# If both are shown, scanner1 should have bad score (NO_RSSI_VALUE = -127)
1700+
for line in caplog.text.splitlines():
1701+
if "connection path(s)" in line:
1702+
if "Scanner 1" in line:
1703+
# Scanner 1 should show 0 free slots and bad score
1704+
assert "(slots=0/3 free)" in line
1705+
assert "(score=-127)" in line # NO_RSSI_VALUE
1706+
1707+
# Scanner 2 should be present with normal score
1708+
assert "Scanner 2" in line
1709+
assert "(slots=3/3 free)" in line
1710+
# Check for both -70 and -70.0
1711+
assert ("(score=-70)" in line) or ("(score=-70.0)" in line)
1712+
break
1713+
1714+
cancel1()
1715+
cancel2()

0 commit comments

Comments
 (0)