|
5 | 5 | import asyncio |
6 | 6 | import logging |
7 | 7 | from collections.abc import Generator |
8 | | -from contextlib import contextmanager |
| 8 | +from contextlib import contextmanager, suppress |
9 | 9 | from typing import Any |
10 | 10 | from unittest.mock import Mock, patch |
11 | 11 |
|
|
14 | 14 | from bleak.backends.device import BLEDevice |
15 | 15 | from bleak.backends.scanner import AdvertisementData |
16 | 16 | from bleak.exc import BleakError |
| 17 | +from bleak_retry_connector import Allocations |
17 | 18 | from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME |
18 | 19 |
|
19 | 20 | from habluetooth import BaseHaRemoteScanner, HaBluetoothConnector |
@@ -1465,3 +1466,250 @@ def is_connected(self) -> bool: |
1465 | 1466 | assert mock_mgmt_ctl.load_conn_params.call_count == 0 |
1466 | 1467 |
|
1467 | 1468 | 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