Skip to content

Commit 0d538e8

Browse files
authored
feat: add auto pair support for bleak 1.x (#173)
1 parent d156e7e commit 0d538e8

File tree

2 files changed

+227
-9
lines changed

2 files changed

+227
-9
lines changed

src/bleak_esphome/backend/client.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -282,11 +282,6 @@ async def connect(
282282
Boolean representing connection status.
283283
284284
"""
285-
if pair:
286-
_LOGGER.warning(
287-
"Explicit pairing during connect is not available in ESPHome. "
288-
"Use the pair() method after connecting if needed."
289-
)
290285
await self._wait_for_free_connection_slot(CONNECT_FREE_SLOT_TIMEOUT)
291286
cache = self._cache
292287

@@ -335,6 +330,9 @@ async def connect(
335330
raise
336331
await connected_future
337332

333+
if pair:
334+
await self._pair()
335+
338336
try:
339337
await self._get_services(
340338
dangerous_use_bleak_cache=dangerous_use_bleak_cache
@@ -384,7 +382,16 @@ def mtu_size(self) -> int:
384382

385383
@api_error_as_bleak_error
386384
async def pair(self, *args: Any, **kwargs: Any) -> None:
387-
"""Attempt to pair."""
385+
"""
386+
Attempt to pair with the device.
387+
388+
Note: Pairing is not available in ESPHome versions < 2024.3.0.
389+
Use the `pair()` method after connecting if pairing is needed.
390+
"""
391+
await self._pair()
392+
393+
async def _pair(self) -> None:
394+
"""Attempt to pair with the device."""
388395
if not self._feature_flags & BluetoothProxyFeature.PAIRING.value:
389396
raise NotImplementedError(
390397
"Pairing is not available in this version ESPHome; "

tests/backend/test_client.py

Lines changed: 214 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from aioesphomeapi import (
88
APIClient,
99
APIVersion,
10+
BluetoothDevicePairing,
11+
BluetoothDeviceUnpairing,
1012
BluetoothGATTCharacteristic,
1113
BluetoothGATTDescriptor,
1214
BluetoothGATTService,
@@ -599,7 +601,7 @@ async def test_bleak_client_connect_with_pair_parameter(
599601
esphome_bluetooth_gatt_services: ESPHomeBluetoothGATTServices,
600602
caplog: pytest.LogCaptureFixture,
601603
) -> None:
602-
"""Test connect with pair=True logs a warning."""
604+
"""Test connect with pair=True calls pair method."""
603605
ble_device = generate_ble_device(
604606
"CC:BB:AA:DD:EE:FF", details={"source": ESP_MAC_ADDRESS, "address_type": 1}
605607
)
@@ -619,6 +621,10 @@ async def test_bleak_client_connect_with_pair_parameter(
619621
"bluetooth_gatt_get_services",
620622
return_value=esphome_bluetooth_gatt_services,
621623
),
624+
patch.object(
625+
client,
626+
"_pair",
627+
) as mock_pair,
622628
):
623629
# Test with pair=True
624630
task = asyncio.create_task(bleak_client.connect())
@@ -629,8 +635,7 @@ async def test_bleak_client_connect_with_pair_parameter(
629635
await task
630636

631637
assert client.is_connected
632-
assert "Explicit pairing during connect is not available in ESPHome" in caplog.text
633-
assert "Use the pair() method after connecting if needed" in caplog.text
638+
mock_pair.assert_called_once()
634639

635640
with patch.object(
636641
client._client,
@@ -685,3 +690,209 @@ async def test_esphome_client_connect_with_pair_false(
685690
await client.disconnect()
686691

687692
mock_disconnect.assert_called_once()
693+
694+
695+
@pytest.mark.asyncio
696+
async def test_pair_success(
697+
client_data: ESPHomeClientData,
698+
) -> None:
699+
"""Test successful pairing."""
700+
ble_device = generate_ble_device(
701+
"CC:BB:AA:DD:EE:FF", details={"source": ESP_MAC_ADDRESS, "address_type": 1}
702+
)
703+
704+
client = ESPHomeClient(ble_device, client_data=client_data)
705+
# Simulate connection
706+
client._is_connected = True
707+
708+
# Enable pairing feature flag
709+
client._feature_flags |= BluetoothProxyFeature.PAIRING.value
710+
711+
with patch.object(
712+
client._client,
713+
"bluetooth_device_pair",
714+
return_value=BluetoothDevicePairing(
715+
address=client._address_as_int,
716+
paired=True,
717+
error=0,
718+
),
719+
) as mock_pair:
720+
await client.pair()
721+
722+
mock_pair.assert_called_once_with(client._address_as_int)
723+
724+
725+
@pytest.mark.asyncio
726+
async def test_pair_failure(
727+
client_data: ESPHomeClientData,
728+
) -> None:
729+
"""Test pairing failure."""
730+
ble_device = generate_ble_device(
731+
"CC:BB:AA:DD:EE:FF", details={"source": ESP_MAC_ADDRESS, "address_type": 1}
732+
)
733+
734+
client = ESPHomeClient(ble_device, client_data=client_data)
735+
# Simulate connection
736+
client._is_connected = True
737+
738+
# Enable pairing feature flag
739+
client._feature_flags |= BluetoothProxyFeature.PAIRING.value
740+
741+
with patch.object(
742+
client._client,
743+
"bluetooth_device_pair",
744+
return_value=BluetoothDevicePairing(
745+
address=client._address_as_int,
746+
paired=False,
747+
error=1,
748+
),
749+
):
750+
with pytest.raises(BleakError) as exc_info:
751+
await client.pair()
752+
assert "Pairing failed due to error: 1" in str(exc_info.value)
753+
754+
755+
@pytest.mark.asyncio
756+
async def test_pair_not_connected(
757+
client_data: ESPHomeClientData,
758+
) -> None:
759+
"""Test pairing when not connected."""
760+
ble_device = generate_ble_device(
761+
"CC:BB:AA:DD:EE:FF", details={"source": ESP_MAC_ADDRESS, "address_type": 1}
762+
)
763+
764+
client = ESPHomeClient(ble_device, client_data=client_data)
765+
# Device is not connected
766+
client._is_connected = False
767+
768+
# Enable pairing feature flag
769+
client._feature_flags |= BluetoothProxyFeature.PAIRING.value
770+
771+
with pytest.raises(BleakError) as exc_info:
772+
await client.pair()
773+
assert "is not connected" in str(exc_info.value)
774+
775+
776+
@pytest.mark.asyncio
777+
async def test_pair_feature_not_supported(
778+
client_data: ESPHomeClientData,
779+
) -> None:
780+
"""Test pairing when feature is not supported."""
781+
ble_device = generate_ble_device(
782+
"CC:BB:AA:DD:EE:FF", details={"source": ESP_MAC_ADDRESS, "address_type": 1}
783+
)
784+
785+
client = ESPHomeClient(ble_device, client_data=client_data)
786+
# Simulate connection
787+
client._is_connected = True
788+
789+
# Disable pairing feature flag
790+
client._feature_flags &= ~BluetoothProxyFeature.PAIRING.value
791+
792+
with pytest.raises(NotImplementedError) as exc_info:
793+
await client.pair()
794+
assert "Pairing is not available in this version ESPHome" in str(exc_info.value)
795+
assert client._device_info.name in str(exc_info.value)
796+
797+
798+
@pytest.mark.asyncio
799+
async def test_unpair_success(
800+
client_data: ESPHomeClientData,
801+
) -> None:
802+
"""Test successful unpairing."""
803+
ble_device = generate_ble_device(
804+
"CC:BB:AA:DD:EE:FF", details={"source": ESP_MAC_ADDRESS, "address_type": 1}
805+
)
806+
807+
client = ESPHomeClient(ble_device, client_data=client_data)
808+
# Simulate connection
809+
client._is_connected = True
810+
811+
# Enable pairing feature flag
812+
client._feature_flags |= BluetoothProxyFeature.PAIRING.value
813+
814+
with patch.object(
815+
client._client,
816+
"bluetooth_device_unpair",
817+
return_value=BluetoothDeviceUnpairing(
818+
address=client._address_as_int,
819+
success=True,
820+
error=0,
821+
),
822+
) as mock_unpair:
823+
await client.unpair()
824+
825+
mock_unpair.assert_called_once_with(client._address_as_int)
826+
827+
828+
@pytest.mark.asyncio
829+
async def test_unpair_failure(
830+
client_data: ESPHomeClientData,
831+
) -> None:
832+
"""Test unpairing failure."""
833+
ble_device = generate_ble_device(
834+
"CC:BB:AA:DD:EE:FF", details={"source": ESP_MAC_ADDRESS, "address_type": 1}
835+
)
836+
837+
client = ESPHomeClient(ble_device, client_data=client_data)
838+
# Simulate connection
839+
client._is_connected = True
840+
841+
# Enable pairing feature flag
842+
client._feature_flags |= BluetoothProxyFeature.PAIRING.value
843+
844+
with patch.object(
845+
client._client,
846+
"bluetooth_device_unpair",
847+
return_value=BluetoothDeviceUnpairing(
848+
address=client._address_as_int,
849+
success=False,
850+
error=2,
851+
),
852+
):
853+
with pytest.raises(BleakError) as exc_info:
854+
await client.unpair()
855+
assert "Unpairing failed due to error: 2" in str(exc_info.value)
856+
857+
858+
@pytest.mark.asyncio
859+
async def test_unpair_not_connected(
860+
client_data: ESPHomeClientData,
861+
) -> None:
862+
"""Test unpairing when not connected."""
863+
ble_device = generate_ble_device(
864+
"CC:BB:AA:DD:EE:FF", details={"source": ESP_MAC_ADDRESS, "address_type": 1}
865+
)
866+
867+
client = ESPHomeClient(ble_device, client_data=client_data)
868+
# Device is not connected
869+
client._is_connected = False
870+
871+
# Enable pairing feature flag
872+
client._feature_flags |= BluetoothProxyFeature.PAIRING.value
873+
874+
with pytest.raises(BleakError) as exc_info:
875+
await client.unpair()
876+
assert "is not connected" in str(exc_info.value)
877+
878+
879+
@pytest.mark.asyncio
880+
async def test_unpair_feature_not_supported(
881+
client_data: ESPHomeClientData,
882+
) -> None:
883+
"""Test unpairing when feature is not supported."""
884+
ble_device = generate_ble_device(
885+
"CC:BB:AA:DD:EE:FF", details={"source": ESP_MAC_ADDRESS, "address_type": 1}
886+
)
887+
888+
client = ESPHomeClient(ble_device, client_data=client_data)
889+
# Simulate connection
890+
client._is_connected = True
891+
892+
# Disable pairing feature flag
893+
client._feature_flags &= ~BluetoothProxyFeature.PAIRING.value
894+
895+
with pytest.raises(NotImplementedError) as exc_info:
896+
await client.unpair()
897+
assert "Unpairing is not available in this version ESPHome" in str(exc_info.value)
898+
assert client._device_info.name in str(exc_info.value)

0 commit comments

Comments
 (0)